From 65f33f8ae9e419e99ad29aa75b803c0b319ac540 Mon Sep 17 00:00:00 2001 From: Toby Bellwood Date: Wed, 18 Mar 2026 12:22:53 +1100 Subject: [PATCH 1/4] chore: update Dockerfile and BATS tests for MailHog --- .github/workflows/build_and_test.yml | 58 ++-- Dockerfile | 4 +- Makefile | 52 ++++ tests/image_structure.bats | 58 ++++ tests/runtime.bats | 448 +++++++++++++++++++++++++++ 5 files changed, 600 insertions(+), 20 deletions(-) create mode 100644 Makefile create mode 100644 tests/image_structure.bats create mode 100644 tests/runtime.bats diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 9765be8..9d6ffba 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -17,11 +17,11 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: # list of Docker images to use as base name for tags images: | @@ -32,26 +32,26 @@ jobs: org.opencontainers.image.description=MailHog mail testing, configured for use with the pygmy stack - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Establish some SSH keys. - name: Setup SSH @@ -77,29 +77,36 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: # list of Docker images to use as base name for tags images: | ghcr.io/${{ github.repository_owner }}/mailhog flavor: | latest=false + - + name: Set single image tag + id: single_tag + run: | + echo "tag=$(echo '${{ steps.meta.outputs.tags }}' | head -n1)" >> "$GITHUB_OUTPUT" - name: Find and Replace - uses: jacobtomlinson/gha-find-replace@v3 - with: - find: "ghcr.io/pygmystack/mailhog:main" - replace: ${{ steps.meta.outputs.tags }} - include: "examples/**" - - - name: Show changes + env: + IMAGE_TAG: ${{ steps.single_tag.outputs.tag }} run: | - grep -n ghcr examples/* + find examples/ -type f -exec sed -i.bak "s|ghcr.io/pygmystack/mailhog:main|${IMAGE_TAG}|g" {} \; + find examples/ -name "*.bak" -delete + grep -Fn ghcr examples/* - - name: Install pygmy and dockerize via brew + name: Install pygmy, dockerize and bats-core via brew + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: 1 + HOMEBREW_NO_ENV_HINTS: 1 run: | eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"; brew tap pygmystack/pygmy; + brew install bats-core; brew install pygmy; brew install dockerize; echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH; @@ -117,6 +124,21 @@ jobs: name: Show pygmy image versions run: | docker ps -a --filter "label=pygmy.name" + - + name: Pull image for BATS tests + run: | + docker pull ${{ steps.single_tag.outputs.tag }} + docker pull uselagoon/php-8.5-fpm + - + name: Run BATS image structure tests + env: + IMAGE_NAME: ${{ steps.single_tag.outputs.tag }} + run: bats --tap tests/image_structure.bats + - + name: Run BATS runtime tests + env: + IMAGE_NAME: ${{ steps.single_tag.outputs.tag }} + run: bats --tap tests/runtime.bats - name: Export and show configuration - pygmy.basic.yml run: | diff --git a/Dockerfile b/Dockerfile index 5c253be..91faa48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM golang:1.21-alpine3.19 as builder +FROM golang:1.25-alpine3.23 AS builder RUN apk --no-cache add --virtual \ build-dependencies \ git \ && GOPATH=/tmp/gocode go install github.com/mailhog/MailHog@v1.0.1 -FROM alpine:3.19 +FROM alpine:3.23 WORKDIR /bin COPY --from=builder tmp/gocode/bin/MailHog /bin/MailHog EXPOSE 1025 8025 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c41cc9c --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +PHP_FPM_IMAGE ?= uselagoon/php-8.5-fpm +IMAGE_NAME ?= pygmystack/mailhog +IMAGE_TAG ?= test +FULL_IMAGE := $(IMAGE_NAME):$(IMAGE_TAG) + +.DEFAULT_GOAL := help + +.PHONY: help build test test-bats test-structure test-runtime shell clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' + +build: ## Build the Docker image + docker build --tag $(FULL_IMAGE) . + +test: build ## Build the image and run all BATS tests (requires: brew install bats-core) + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + docker pull $(PHP_FPM_IMAGE) + IMAGE_NAME=$(FULL_IMAGE) PHP_FPM_IMAGE=$(PHP_FPM_IMAGE) bats --tap tests/ + +test-bats: ## Run all BATS tests without rebuilding the image + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + docker pull $(PHP_FPM_IMAGE) + IMAGE_NAME=$(FULL_IMAGE) PHP_FPM_IMAGE=$(PHP_FPM_IMAGE) bats --tap tests/ + +test-structure: ## Run image structure tests only (no running container required) + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + IMAGE_NAME=$(FULL_IMAGE) bats --tap tests/image_structure.bats + +test-runtime: ## Run runtime and email-flow tests (starts a MailHog container) + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + docker pull $(PHP_FPM_IMAGE) + IMAGE_NAME=$(FULL_IMAGE) PHP_FPM_IMAGE=$(PHP_FPM_IMAGE) bats --tap tests/runtime.bats + +shell: ## Open an interactive shell inside the container + docker run --rm -it --entrypoint sh $(FULL_IMAGE) + +clean: ## Remove the local test Docker image + docker rmi $(FULL_IMAGE) 2>/dev/null || true diff --git a/tests/image_structure.bats b/tests/image_structure.bats new file mode 100644 index 0000000..f465f0f --- /dev/null +++ b/tests/image_structure.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats +# Image structure tests — verify the binary, exposed ports, entrypoint, and +# working directory baked into the MailHog image. Tests run ephemeral +# containers and do not require a long-running process. + +bats_require_minimum_version 1.5.0 + +IMAGE="${IMAGE_NAME:-pygmystack/mailhog:test}" + +# --------------------------------------------------------------------------- +# Binary +# --------------------------------------------------------------------------- + +@test "MailHog binary is present at /bin/MailHog" { + run docker run --rm --entrypoint sh "${IMAGE}" -c 'test -f /bin/MailHog' + [ "$status" -eq 0 ] +} + +@test "MailHog binary is executable" { + run docker run --rm --entrypoint sh "${IMAGE}" -c 'test -x /bin/MailHog' + [ "$status" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# Exposed ports (image metadata) +# --------------------------------------------------------------------------- + +@test "image declares SMTP port 1025 as exposed" { + run docker inspect --format='{{json .Config.ExposedPorts}}' "${IMAGE}" + [ "$status" -eq 0 ] + [[ "$output" =~ "1025" ]] +} + +@test "image declares HTTP port 8025 as exposed" { + run docker inspect --format='{{json .Config.ExposedPorts}}' "${IMAGE}" + [ "$status" -eq 0 ] + [[ "$output" =~ "8025" ]] +} + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + +@test "image entrypoint is MailHog" { + run docker inspect --format='{{json .Config.Entrypoint}}' "${IMAGE}" + [ "$status" -eq 0 ] + [[ "$output" =~ "MailHog" ]] +} + +# --------------------------------------------------------------------------- +# Working directory +# --------------------------------------------------------------------------- + +@test "image working directory is /bin" { + run docker inspect --format='{{.Config.WorkingDir}}' "${IMAGE}" + [ "$status" -eq 0 ] + [ "$output" = "/bin" ] +} diff --git a/tests/runtime.bats b/tests/runtime.bats new file mode 100644 index 0000000..ff2dc2e --- /dev/null +++ b/tests/runtime.bats @@ -0,0 +1,448 @@ +#!/usr/bin/env bats +# Runtime tests — start a MailHog container and exercise SMTP reception and +# the HTTP API. +# +# A dedicated container is started once in setup_file() and torn down in +# teardown_file(). The container is placed on a private Docker network so +# that Lagoon-style PHP container tests can reach MailHog via the well-known +# hostname "amazeeio-mailhog" (matching the alias used by Lagoon's 50-ssmtp.sh +# entrypoint). Host-mapped ports are used for curl-based SMTP sends and +# API assertions so that the tests work on both Linux and macOS (Docker +# Desktop). +# +# Tests are ordered intentionally: the empty-mailbox assertions run before +# any email-send tests; deletion tests run last. + +bats_require_minimum_version 1.5.0 + +IMAGE="${IMAGE_NAME:-pygmystack/mailhog:test}" +PHP_FPM_IMAGE="${PHP_FPM_IMAGE:-uselagoon/php-8.5-fpm}" + +# Populated in setup() from files written by setup_file(). +MAILHOG_CONTAINER="" +MAILHOG_NETWORK="" +MAILHOG_ISOLATED_NETWORK="" +SMTP_PORT="" +HTTP_PORT="" + +# --------------------------------------------------------------------------- +# File-level setup / teardown +# --------------------------------------------------------------------------- + +setup_file() { + local suffix + suffix="$(openssl rand -hex 4)" + echo "${suffix}" > "${BATS_SUITE_TMPDIR}/.suffix" + + local container="mailhog-bats-test-${suffix}" + local network="mailhog-bats-net-${suffix}" + + # Pre-pull the Lagoon PHP image so it is locally cached before any test + # runs. Without this, the first test to use the image would trigger an + # inline pull, and the short default ssmtp/sendmail connection timeout + # could expire against the still-starting container. + docker pull "${PHP_FPM_IMAGE}" + + # Clean up any leftovers from a previous (failed) run. + docker rm -f "${container}" 2>/dev/null || true + docker network rm "${network}" 2>/dev/null || true + + # Create a dedicated network so the Lagoon-style PHP container tests can + # reach MailHog via the "amazeeio-mailhog" hostname without any extra setup. + docker network create "${network}" + echo "${network}" > "${BATS_SUITE_TMPDIR}/.network" + + # Create an isolated (--internal) network for the host.docker.internal + # tests. An internal network has no host routing, so the + # `nc -z -w 1 172.17.0.1 1025` probe in 50-ssmtp.sh reliably fails even + # when a pygmy MailHog is listening on the host machine. This ensures the + # host.docker.internal branch is the first one that can succeed. + local isolated_network + isolated_network="${network}-isolated" + docker network create --internal "${isolated_network}" + echo "${isolated_network}" > "${BATS_SUITE_TMPDIR}/.isolated_network" + + # Start MailHog with auto-assigned host ports and on the test network. + docker run -d \ + --name "${container}" \ + --network "${network}" \ + --network-alias "amazeeio-mailhog" \ + -p 1025 \ + -p 8025 \ + "${IMAGE}" + + # Also connect MailHog to the isolated network so that host.docker.internal + # tests can reach it without going via the host. + docker network connect --alias amazeeio-mailhog "${isolated_network}" "${container}" + + # Discover the host-mapped ports (handles both 0.0.0.0:PORT and [::]:PORT). + local smtp_port http_port + smtp_port="$(docker port "${container}" 1025/tcp | grep -oE '[0-9]+$' | head -1)" + http_port="$(docker port "${container}" 8025/tcp | grep -oE '[0-9]+$' | head -1)" + echo "${smtp_port}" > "${BATS_SUITE_TMPDIR}/.smtp_port" + echo "${http_port}" > "${BATS_SUITE_TMPDIR}/.http_port" + + # Wait up to 30 seconds for the HTTP API to become ready. + local max_wait=30 waited=0 + until curl -sf "http://localhost:${http_port}/api/v2/messages" >/dev/null 2>&1; do + sleep 1 + waited=$((waited + 1)) + if [ "${waited}" -ge "${max_wait}" ]; then + echo "# Timed out waiting for MailHog HTTP API on port ${http_port}" >&3 + docker logs "${container}" >&3 2>&3 + return 1 + fi + done +} + +teardown_file() { + local suffix network isolated_network + suffix="$(cat "${BATS_SUITE_TMPDIR}/.suffix" 2>/dev/null || true)" + network="$(cat "${BATS_SUITE_TMPDIR}/.network" 2>/dev/null || true)" + isolated_network="$(cat "${BATS_SUITE_TMPDIR}/.isolated_network" 2>/dev/null || true)" + docker rm -f "mailhog-bats-test-${suffix}" 2>/dev/null || true + docker network rm "${isolated_network}" 2>/dev/null || true + docker network rm "${network}" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Per-test setup — restore variables from the files written by setup_file(). +# --------------------------------------------------------------------------- + +setup() { + local suffix + suffix="$(cat "${BATS_SUITE_TMPDIR}/.suffix" 2>/dev/null || true)" + MAILHOG_CONTAINER="mailhog-bats-test-${suffix}" + MAILHOG_NETWORK="$(cat "${BATS_SUITE_TMPDIR}/.network" 2>/dev/null || true)" + MAILHOG_ISOLATED_NETWORK="$(cat "${BATS_SUITE_TMPDIR}/.isolated_network" 2>/dev/null || true)" + SMTP_PORT="$(cat "${BATS_SUITE_TMPDIR}/.smtp_port" 2>/dev/null || true)" + HTTP_PORT="$(cat "${BATS_SUITE_TMPDIR}/.http_port" 2>/dev/null || true)" +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# send_test_email FROM TO SUBJECT BODY +# Sends an RFC 2822 email to the running MailHog over SMTP using curl. +send_test_email() { + local from="${1:-sender@example.com}" + local to="${2:-recipient@example.com}" + local subject="${3:-BATS Test Email}" + local body="${4:-This is a test email sent by BATS.}" + + curl --silent --show-error \ + --url "smtp://localhost:${SMTP_PORT}" \ + --mail-from "${from}" \ + --mail-rcpt "${to}" \ + --upload-file - </dev/null +} + +# message_total — returns the integer value of the "total" field from the v2 API. +message_total() { + curl -sf "http://localhost:${HTTP_PORT}/api/v2/messages" \ + | grep -o '"total":[0-9]*' | cut -d: -f2 +} + +# --------------------------------------------------------------------------- +# Container lifecycle +# --------------------------------------------------------------------------- + +@test "container is running" { + run docker inspect --format='{{.State.Status}}' "${MAILHOG_CONTAINER}" + [ "$status" -eq 0 ] + [ "$output" = "running" ] +} + +@test "MailHog process is running inside the container" { + run docker exec "${MAILHOG_CONTAINER}" sh -c 'ps | grep "[M]ailHog"' + [ "$status" -eq 0 ] + [ -n "$output" ] +} + +# --------------------------------------------------------------------------- +# HTTP UI and API +# --------------------------------------------------------------------------- + +@test "MailHog web UI responds with HTML containing 'MailHog'" { + run curl -sf "http://localhost:${HTTP_PORT}/" + [ "$status" -eq 0 ] + [[ "$output" =~ "MailHog" ]] +} + +@test "MailHog API v2 messages endpoint returns a JSON response with a 'total' field" { + run curl -sf "http://localhost:${HTTP_PORT}/api/v2/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "total" ]] +} + +@test "MailHog API v1 messages endpoint returns a JSON array or null" { + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + # Response is a JSON array when messages exist, or null when empty. + [[ "$output" =~ ^\[ ]] || [ "$output" = "null" ] +} + +# --------------------------------------------------------------------------- +# Email reception — empty state +# --------------------------------------------------------------------------- + +@test "message count is zero before any email is sent" { + delete_all_messages + local total + total="$(message_total)" + [ "${total}" = "0" ] +} + +# --------------------------------------------------------------------------- +# Email reception — sending via SMTP +# --------------------------------------------------------------------------- + +@test "an email sent via SMTP is captured by MailHog" { + delete_all_messages + send_test_email \ + "sender@example.com" \ + "recipient@example.com" \ + "BATS Test Email" \ + "Hello from BATS." + local total + total="$(message_total)" + [ "${total}" = "1" ] +} + +@test "captured email has the correct From address" { + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "sender@example.com" ]] +} + +@test "captured email has the correct To address" { + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "recipient@example.com" ]] +} + +@test "captured email has the correct Subject" { + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "BATS Test Email" ]] +} + +@test "captured email body contains expected content" { + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "Hello from BATS" ]] +} + +# --------------------------------------------------------------------------- +# Email deletion +# --------------------------------------------------------------------------- + +@test "all messages can be deleted via the MailHog API" { + run curl -sf -X DELETE "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] +} + +@test "message count is zero after deleting all messages" { + local total + total="$(message_total)" + [ "${total}" = "0" ] +} + +# --------------------------------------------------------------------------- +# Multiple messages +# --------------------------------------------------------------------------- + +@test "multiple emails sent via SMTP all appear in MailHog" { + delete_all_messages + send_test_email "a@example.com" "x@example.com" "Email One" "Body one." + send_test_email "b@example.com" "y@example.com" "Email Two" "Body two." + send_test_email "c@example.com" "z@example.com" "Email Three" "Body three." + local total + total="$(message_total)" + [ "${total}" = "3" ] + delete_all_messages +} + +# --------------------------------------------------------------------------- +# Lagoon-style email send — simulates the 50-ssmtp.sh entrypoint +# +# Uses the real uselagoon/php-8.5-fpm image with ssmtp installed and the +# 50-ssmtp.sh entrypoint at /lagoon/entrypoints/50-ssmtp.sh. The script is +# dot-sourced so its `return` statements are handled correctly, then the +# ssmtp sendmail binary delivers the message — exactly as a live Lagoon PHP +# container would when SSMTP_MAILHUB is set by an operator. +# --------------------------------------------------------------------------- + +@test "email sent via Lagoon PHP container (ssmtp) is captured by MailHog" { + delete_all_messages + + # Pass SSMTP_MAILHUB so 50-ssmtp.sh writes mailhub=amazeeio-mailhog:1025 + # into /etc/ssmtp/ssmtp.conf, then send via the real ssmtp sendmail binary. + run docker run --rm \ + --network "${MAILHOG_NETWORK}" \ + -e "SSMTP_MAILHUB=amazeeio-mailhog:1025" \ + --entrypoint sh \ + "${PHP_FPM_IMAGE}" -euc ' + . /lagoon/entrypoints/50-ssmtp.sh + printf "To: dev@example.com\nFrom: lagoon-test@example.com\nSubject: Lagoon BATS Test\n\nSent via ssmtp as Lagoon would.\n" \ + | sendmail -t + ' + [ "$status" -eq 0 ] + + # Verify the message arrived in MailHog. + local total + total="$(message_total)" + [ "${total}" -ge "1" ] + + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "Lagoon BATS Test" ]] + + delete_all_messages +} + +# --------------------------------------------------------------------------- +# SSMTP_MAILHUB env var — simulates lines 20-21 of 50-ssmtp.sh +# +# When SSMTP_MAILHUB is explicitly set, Lagoon's 50-ssmtp.sh writes: +# mailhub=${SSMTP_MAILHUB} +# directly into /etc/ssmtp/ssmtp.conf and skips all auto-detection. +# These tests verify that a client honouring SSMTP_MAILHUB successfully +# delivers mail to MailHog using the configured hub value. +# --------------------------------------------------------------------------- + +@test "email sent with SSMTP_MAILHUB set to 'amazeeio-mailhog:1025' is captured by MailHog" { + delete_all_messages + + # The real 50-ssmtp.sh writes "mailhub=${SSMTP_MAILHUB}" into + # /etc/ssmtp/ssmtp.conf when SSMTP_MAILHUB is set (lines 20-21). Dot- + # source so `return` is handled correctly, then send via ssmtp sendmail. + run docker run --rm \ + --network "${MAILHOG_NETWORK}" \ + -e "SSMTP_MAILHUB=amazeeio-mailhog:1025" \ + --entrypoint sh \ + "${PHP_FPM_IMAGE}" -euc ' + . /lagoon/entrypoints/50-ssmtp.sh + printf "To: dev@example.com\nFrom: ssmtp-mailhub-test@example.com\nSubject: SSMTP_MAILHUB Test\n\nSent via SSMTP_MAILHUB.\n" \ + | sendmail -t + ' + [ "$status" -eq 0 ] + + local total + total="$(message_total)" + [ "${total}" -ge "1" ] + + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "SSMTP_MAILHUB Test" ]] + + delete_all_messages +} + +@test "email sent with SSMTP_MAILHUB overrides auto-detection and reaches MailHog" { + delete_all_messages + + # Confirms the override semantics: SSMTP_MAILHUB is the first branch in + # 50-ssmtp.sh's if/elif chain so it short-circuits all auto-detection + # (172.17.0.1, host.docker.internal, LAGOON_PROJECT) regardless of what + # else might be reachable on the network. + run docker run --rm \ + --network "${MAILHOG_NETWORK}" \ + -e "SSMTP_MAILHUB=amazeeio-mailhog:1025" \ + --entrypoint sh \ + "${PHP_FPM_IMAGE}" -euc ' + . /lagoon/entrypoints/50-ssmtp.sh + printf "To: dev@example.com\nFrom: override-test@example.com\nSubject: SSMTP_MAILHUB Override Test\n\nSMTP_MAILHUB takes priority.\n" \ + | sendmail -t + ' + [ "$status" -eq 0 ] + + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "SSMTP_MAILHUB Override Test" ]] + + delete_all_messages +} + +# --------------------------------------------------------------------------- +# host.docker.internal — simulates lines 26-29 of 50-ssmtp.sh +# +# When neither SSMTP_MAILHUB nor 172.17.0.1:1025 is available, Lagoon's +# 50-ssmtp.sh tries `nc -z -w 1 host.docker.internal 1025`. If that +# succeeds it writes "mailhub=host.docker.internal:1025" into ssmtp.conf. +# +# An --internal Docker network is used here so that the 172.17.0.1:1025 nc +# probe reliably fails even when a pygmy MailHog is listening on the host +# machine (internal networks have no host routing). The MailHog container is +# connected to both the regular and the isolated network; its IP on the isolated +# network is injected as host.docker.internal via --add-host so that the nc +# probe and ssmtp sendmail both resolve to the BATS MailHog. +# --------------------------------------------------------------------------- + +@test "email sent via host.docker.internal route is captured by MailHog" { + delete_all_messages + + # Use the isolated (--internal) network so that the nc probe to + # 172.17.0.1:1025 in 50-ssmtp.sh reliably fails — even when a host-level + # pygmy MailHog is running — and the host.docker.internal branch becomes + # the first probe to succeed. The MailHog IP on the isolated network is + # injected via --add-host so that nc and ssmtp both resolve it correctly. + local mailhog_isolated_ip + mailhog_isolated_ip="$(docker inspect \ + --format="{{(index .NetworkSettings.Networks \"${MAILHOG_ISOLATED_NETWORK}\").IPAddress}}" \ + "${MAILHOG_CONTAINER}")" + + run docker run --rm \ + --network "${MAILHOG_ISOLATED_NETWORK}" \ + --add-host "host.docker.internal:${mailhog_isolated_ip}" \ + --entrypoint sh \ + "${PHP_FPM_IMAGE}" -euc ' + . /lagoon/entrypoints/50-ssmtp.sh + printf "To: dev@example.com\nFrom: hdi-test@example.com\nSubject: host.docker.internal Test\n\nSent via host.docker.internal.\n" \ + | sendmail -t + ' + [ "$status" -eq 0 ] + + local total + total="$(message_total)" + [ "${total}" -ge "1" ] + + run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" + [ "$status" -eq 0 ] + [[ "$output" =~ "host.docker.internal Test" ]] + + delete_all_messages +} + +@test "nc probe to host.docker.internal:1025 succeeds when MailHog is reachable" { + # Verify the nc connectivity check itself — the condition that 50-ssmtp.sh + # evaluates before writing mailhub=host.docker.internal:1025. + local mailhog_ip + mailhog_ip="$(docker inspect \ + --format="{{(index .NetworkSettings.Networks \"${MAILHOG_NETWORK}\").IPAddress}}" \ + "${MAILHOG_CONTAINER}")" + + local mailhog_isolated_ip + mailhog_isolated_ip="$(docker inspect \ + --format="{{(index .NetworkSettings.Networks \"${MAILHOG_ISOLATED_NETWORK}\").IPAddress}}" \ + "${MAILHOG_CONTAINER}")" + + run docker run --rm \ + --network "${MAILHOG_ISOLATED_NETWORK}" \ + --add-host "host.docker.internal:${mailhog_isolated_ip}" \ + --entrypoint sh \ + "${PHP_FPM_IMAGE}" -euc 'nc -z -w 1 host.docker.internal 1025' + [ "$status" -eq 0 ] +} From 8ad34e949f31f619de82184823daafc1850e3d68 Mon Sep 17 00:00:00 2001 From: Toby Bellwood Date: Wed, 18 Mar 2026 12:35:59 +1100 Subject: [PATCH 2/4] test: fixup actions docker-compose --- .github/workflows/build_and_test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 9d6ffba..7d5fc0a 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -223,14 +223,14 @@ jobs: name: "[Example] Drupal Base" run: | cd lagoon-examples/drupal-base; - docker-compose -p drupal-base up -d; - docker-compose -p drupal-base exec -T cli composer install; + docker compose -p drupal-base up -d; + docker compose -p drupal-base exec -T cli composer install; dockerize -wait http://drupal-base.docker.amazee.io:80 -timeout 10s; curl --HEAD http://drupal-base.docker.amazee.io; curl --HEAD http://drupal-base.docker.amazee.io | grep -i "x-lagoon"; pygmy --config examples/pygmy.yml status | grep '\- http://drupal-base.docker.amazee.io'; - docker-compose -p drupal-base down; - docker-compose -p drupal-base rm; + docker compose -p drupal-base down; + docker compose -p drupal-base rm; cd ../../; - name: Test the stop command From 2114f796c37097809b97e3df51ee50ac3da1b1e0 Mon Sep 17 00:00:00 2001 From: Toby Bellwood Date: Wed, 18 Mar 2026 12:41:11 +1100 Subject: [PATCH 3/4] test: reorder bats tests --- .github/workflows/build_and_test.yml | 44 +++++++++++++--------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 7d5fc0a..b275627 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -61,7 +61,7 @@ jobs: test: needs: docker - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -98,19 +98,32 @@ jobs: find examples/ -name "*.bak" -delete grep -Fn ghcr examples/* - - name: Install pygmy, dockerize and bats-core via brew + name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@ebe7cd12aeb3c69ccf1a6ba5a4eba5bfdfb00b3b # main + - + name: Install homebrew packages env: HOMEBREW_NO_AUTO_UPDATE: 1 HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: 1 HOMEBREW_NO_ENV_HINTS: 1 run: | - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"; - brew tap pygmystack/pygmy; brew install bats-core; - brew install pygmy; brew install dockerize; - echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH; + brew install pygmystack/pygmy/pygmy; pygmy version; + - + name: Pull image for tests + run: | + docker pull ${{ steps.single_tag.outputs.tag }} + docker pull uselagoon/php-8.5-fpm + - + name: Run BATS tests + env: + IMAGE_NAME: ${{ steps.single_tag.outputs.tag }} + PHP_FPM_IMAGE: uselagoon/php-8.5-fpm + run: | + bats --tap tests/image_structure.bats + bats --tap tests/runtime.bats - name: Switch pygmy configs from vanilla to basic run: | @@ -124,28 +137,13 @@ jobs: name: Show pygmy image versions run: | docker ps -a --filter "label=pygmy.name" - - - name: Pull image for BATS tests - run: | - docker pull ${{ steps.single_tag.outputs.tag }} - docker pull uselagoon/php-8.5-fpm - - - name: Run BATS image structure tests - env: - IMAGE_NAME: ${{ steps.single_tag.outputs.tag }} - run: bats --tap tests/image_structure.bats - - - name: Run BATS runtime tests - env: - IMAGE_NAME: ${{ steps.single_tag.outputs.tag }} - run: bats --tap tests/runtime.bats - name: Export and show configuration - pygmy.basic.yml run: | pygmy --config examples/pygmy.basic.yml export -o ./exported-config.yml cat ./exported-config.yml echo "Checking image references in started containers..."; - docker container inspect amazeeio-mailhog | jq '.[].Config.Image' | grep '${{ steps.meta.outputs.tags }}'; + docker container inspect amazeeio-mailhog | jq '.[].Config.Image' | grep -F '${{ steps.single_tag.outputs.tag }}'; - name: Resolv file test run: | @@ -207,7 +205,7 @@ jobs: pygmy --config examples/pygmy.yml export -o ./exported-config-2.yml cat ./exported-config-2.yml echo "Checking image references in started containers..."; - docker container inspect amazeeio-mailhog | jq '.[].Config.Image' | grep '${{ steps.meta.outputs.tags }}'; + docker container inspect amazeeio-mailhog | jq '.[].Config.Image' | grep -F '${{ steps.single_tag.outputs.tag }}'; - name: SSH Key test run: | From 05e99cad9b5cddd621763cfe2cfc04a6660500fb Mon Sep 17 00:00:00 2001 From: Toby Bellwood Date: Wed, 18 Mar 2026 17:27:17 +1100 Subject: [PATCH 4/4] tests: address copilot review --- tests/runtime.bats | 33 ++++++--------------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/tests/runtime.bats b/tests/runtime.bats index ff2dc2e..dfa3cce 100644 --- a/tests/runtime.bats +++ b/tests/runtime.bats @@ -209,52 +209,36 @@ message_total() { # Email reception — sending via SMTP # --------------------------------------------------------------------------- -@test "an email sent via SMTP is captured by MailHog" { +@test "an email sent via SMTP is captured with correct From, To, Subject and body" { delete_all_messages send_test_email \ "sender@example.com" \ "recipient@example.com" \ "BATS Test Email" \ "Hello from BATS." + local total total="$(message_total)" [ "${total}" = "1" ] -} -@test "captured email has the correct From address" { run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" [ "$status" -eq 0 ] [[ "$output" =~ "sender@example.com" ]] -} - -@test "captured email has the correct To address" { - run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" - [ "$status" -eq 0 ] [[ "$output" =~ "recipient@example.com" ]] -} - -@test "captured email has the correct Subject" { - run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" - [ "$status" -eq 0 ] [[ "$output" =~ "BATS Test Email" ]] -} - -@test "captured email body contains expected content" { - run curl -sf "http://localhost:${HTTP_PORT}/api/v1/messages" - [ "$status" -eq 0 ] [[ "$output" =~ "Hello from BATS" ]] + + delete_all_messages } # --------------------------------------------------------------------------- # Email deletion # --------------------------------------------------------------------------- -@test "all messages can be deleted via the MailHog API" { +@test "all messages can be deleted via the MailHog API and count returns to zero" { + send_test_email "sender@example.com" "recipient@example.com" "Delete Test" "To be deleted." run curl -sf -X DELETE "http://localhost:${HTTP_PORT}/api/v1/messages" [ "$status" -eq 0 ] -} - -@test "message count is zero after deleting all messages" { local total total="$(message_total)" [ "${total}" = "0" ] @@ -429,11 +413,6 @@ message_total() { @test "nc probe to host.docker.internal:1025 succeeds when MailHog is reachable" { # Verify the nc connectivity check itself — the condition that 50-ssmtp.sh # evaluates before writing mailhub=host.docker.internal:1025. - local mailhog_ip - mailhog_ip="$(docker inspect \ - --format="{{(index .NetworkSettings.Networks \"${MAILHOG_NETWORK}\").IPAddress}}" \ - "${MAILHOG_CONTAINER}")" - local mailhog_isolated_ip mailhog_isolated_ip="$(docker inspect \ --format="{{(index .NetworkSettings.Networks \"${MAILHOG_ISOLATED_NETWORK}\").IPAddress}}" \