diff --git a/test/helper/common.bash b/test/helper/common.bash new file mode 100644 index 00000000..6df8a2de --- /dev/null +++ b/test/helper/common.bash @@ -0,0 +1,229 @@ +#!/bin/bash + +function __load_bats_helper() { + load "${REPOSITORY_ROOT}/test/test_helper/bats-support/load" + load "${REPOSITORY_ROOT}/test/test_helper/bats-assert/load" +} + +__load_bats_helper + +# ------------------------------------------------------------------- + +function _run_in_container() { + run docker exec "${CONTAINER_NAME}" "${@}" +} + +function _default_teardown() { + docker rm -f "${CONTAINER_NAME}" +} + +# ------------------------------------------------------------------- + +# @param ${1} timeout +# @param --fatal-test additional test whose failure aborts immediately +# @param ... test to run +function repeat_until_success_or_timeout { + local FATAL_FAILURE_TEST_COMMAND + + if [[ "${1}" == "--fatal-test" ]]; then + FATAL_FAILURE_TEST_COMMAND="${2}" + shift 2 + fi + + if ! [[ "${1}" =~ ^[0-9]+$ ]]; then + echo "First parameter for timeout must be an integer, received \"${1}\"" + return 1 + fi + + local TIMEOUT=${1} + local STARTTIME=${SECONDS} + shift 1 + + until "${@}" + do + if [[ -n ${FATAL_FAILURE_TEST_COMMAND} ]] && ! eval "${FATAL_FAILURE_TEST_COMMAND}"; then + echo "\`${FATAL_FAILURE_TEST_COMMAND}\` failed, early aborting repeat_until_success of \`${*}\`" >&2 + return 1 + fi + + sleep 1 + + if [[ $(( SECONDS - STARTTIME )) -gt ${TIMEOUT} ]]; then + echo "Timed out on command: ${*}" >&2 + return 1 + fi + done +} + +# like repeat_until_success_or_timeout but with wrapping the command to run into `run` for later bats consumption +# @param ${1} timeout +# @param ... test command to run +function run_until_success_or_timeout { + if ! [[ ${1} =~ ^[0-9]+$ ]]; then + echo "First parameter for timeout must be an integer, received \"${1}\"" + return 1 + fi + + local TIMEOUT=${1} + local STARTTIME=${SECONDS} + shift 1 + + until run "${@}" && [[ $status -eq 0 ]] + do + sleep 1 + + if (( SECONDS - STARTTIME > TIMEOUT )); then + echo "Timed out on command: ${*}" >&2 + return 1 + fi + done +} + +# @param ${1} timeout +# @param ${2} container name +# @param ... test command for container +function repeat_in_container_until_success_or_timeout() { + local TIMEOUT="${1}" + local CONTAINER_NAME="${2}" + shift 2 + + repeat_until_success_or_timeout --fatal-test "container_is_running ${CONTAINER_NAME}" "${TIMEOUT}" docker exec "${CONTAINER_NAME}" "${@}" +} + +function container_is_running() { + [[ "$(docker inspect -f '{{.State.Running}}' "${1}")" == "true" ]] +} + +# @param ${1} port +# @param ${2} container name +function wait_for_tcp_port_in_container() { + repeat_until_success_or_timeout --fatal-test "container_is_running ${2}" "${TEST_TIMEOUT_IN_SECONDS}" docker exec "${2}" /bin/sh -c "nc -z 0.0.0.0 ${1}" +} + +# @param ${1} name of the postfix container +function wait_for_smtp_port_in_container() { + wait_for_tcp_port_in_container 25 "${1}" +} + +# @param ${1} name of the postfix container +function wait_for_smtp_port_in_container_to_respond() { + local COUNT=0 + until [[ $(docker exec "${1}" timeout 10 /bin/sh -c "echo QUIT | nc localhost 25") == *"221 2.0.0 Bye"* ]]; do + if [[ $COUNT -eq 20 ]] + then + echo "Unable to receive a valid response from 'nc localhost 25' within 20 seconds" + return 1 + fi + + sleep 1 + ((COUNT+=1)) + done +} + +# @param ${1} name of the postfix container +function wait_for_amavis_port_in_container() { + wait_for_tcp_port_in_container 10024 "${1}" +} + +# get the private config path for the given container or test file, if no container name was given +function private_config_path() { + echo "${PWD}/test/duplicate_configs/${1:-$(basename "${BATS_TEST_FILENAME}")}" +} + +function container_has_service_running() { + local CONTAINER_NAME="${1}" + local SERVICE_NAME="${2}" + + docker exec "${CONTAINER_NAME}" /usr/bin/supervisorctl status "${SERVICE_NAME}" | grep RUNNING >/dev/null +} + +function wait_for_service() { + local CONTAINER_NAME="${1}" + local SERVICE_NAME="${2}" + + repeat_until_success_or_timeout --fatal-test "container_is_running ${CONTAINER_NAME}" "${TEST_TIMEOUT_IN_SECONDS}" \ + container_has_service_running "${CONTAINER_NAME}" "${SERVICE_NAME}" +} + +function wait_for_changes_to_be_detected_in_container() { + local CONTAINER_NAME="${1}" + local TIMEOUT=${TEST_TIMEOUT_IN_SECONDS} + + # shellcheck disable=SC2016 + repeat_in_container_until_success_or_timeout "${TIMEOUT}" "${CONTAINER_NAME}" bash -c 'source /usr/local/bin/helpers/index.sh; _obtain_hostname_and_domainname; cmp --silent -- <(_monitored_files_checksums) "${CHKSUM_FILE}" >/dev/null' +} + +# Relies on ENV `LOG_LEVEL=debug` or higher +function wait_until_change_detection_event_completes() { + local CONTAINER_NAME="${1}" + # Ensure early failure if arg is missing: + assert_not_equal "${CONTAINER_NAME}" "" + + # Ensure the container is configured with the required `LOG_LEVEL` ENV: + assert_regex \ + $(docker exec "${CONTAINER_NAME}" env | grep '^LOG_LEVEL=') \ + '=(debug|trace)$' + + local CHANGE_EVENT_START='Change detected' + local CHANGE_EVENT_END='Completed handling of detected change' # debug log + + function __change_event_status() { + docker exec "${CONTAINER_NAME}" \ + grep -oE "${CHANGE_EVENT_START}|${CHANGE_EVENT_END}" /var/log/supervisor/changedetector.log \ + | tail -1 + } + + function __is_changedetector_processing() { + [[ $(__change_event_status) == "${CHANGE_EVENT_START}" ]] + } + + function __is_changedetector_finished() { + [[ $(__change_event_status) == "${CHANGE_EVENT_END}" ]] + } + + if [[ ! $(__is_changedetector_processing) ]] + then + # A new change event is expected, wait for it: + repeat_until_success_or_timeout 60 __is_changedetector_processing + fi + + # Change event is in progress, wait until it finishes: + repeat_until_success_or_timeout 60 __is_changedetector_finished + + # NOTE: Although the change event has completed, services like Postfix and Dovecot + # may still be in the process of restarting. + # You may still want to wait longer if depending on those to be ready. +} + +# An account added to `postfix-accounts.cf` must wait for the `changedetector` service +# to process the update before Dovecot creates the mail account and associated storage dir: +function wait_until_account_maildir_exists() { + local CONTAINER_NAME=$1 + local MAIL_ACCOUNT=$2 + + local LOCAL_PART="${MAIL_ACCOUNT%@*}" + local DOMAIN_PART="${MAIL_ACCOUNT#*@}" + local MAIL_ACCOUNT_STORAGE_DIR="/var/mail/${DOMAIN_PART}/${LOCAL_PART}" + + repeat_in_container_until_success_or_timeout 60 "${CONTAINER_NAME}" bash -c "[[ -d ${MAIL_ACCOUNT_STORAGE_DIR} ]]" +} + +function add_mail_account_then_wait_until_ready() { + local CONTAINER_NAME=$1 + local MAIL_ACCOUNT=$2 + # Password is optional (omit when the password is not needed during the test) + local MAIL_PASS="${3:-password_not_relevant_to_test}" + + run docker exec "${CONTAINER_NAME}" setup email add "${MAIL_ACCOUNT}" "${MAIL_PASS}" + assert_success + + wait_until_account_maildir_exists "${CONTAINER_NAME}" "${MAIL_ACCOUNT}" +} + +function wait_for_empty_mail_queue_in_container() { + local CONTAINER_NAME="${1}" + local TIMEOUT=${TEST_TIMEOUT_IN_SECONDS} + + # shellcheck disable=SC2016 + repeat_in_container_until_success_or_timeout "${TIMEOUT}" "${CONTAINER_NAME}" bash -c '[[ $(mailq) == *"Mail queue is empty"* ]]' +} diff --git a/test/helper/setup.bash b/test/helper/setup.bash new file mode 100644 index 00000000..061c07ac --- /dev/null +++ b/test/helper/setup.bash @@ -0,0 +1,133 @@ +#!/bin/bash + +# ------------------------------------------------------------------- + +function __initialize_variables() { + function __check_if_set() { + if [[ ${!1+set} != 'set' ]] + then + echo "ERROR: (helper/setup.sh) '${1:?No variable name given to __check_if_set}' is not set" >&2 + exit 1 + fi + } + + local REQUIRED_VARIABLES_FOR_TESTS=( + 'REPOSITORY_ROOT' + 'IMAGE_NAME' + 'CONTAINER_NAME' + ) + + for VARIABLE in "${REQUIRED_VARIABLES_FOR_TESTS}" + do + __check_if_set "${VARIABLE}" + done + + TEST_TIMEOUT_IN_SECONDS=${TEST_TIMEOUT_IN_SECONDS:-120} + NUMBER_OF_LOG_LINES=${NUMBER_OF_LOG_LINES:-10} + SETUP_FILE_MARKER="${BATS_TMPDIR:?}/$(basename "${BATS_TEST_FILENAME:?}").setup_file" +} + +# ------------------------------------------------------------------- + +# @param ${1} relative source in test/config folder +# @param ${2} (optional) container name, defaults to ${BATS_TEST_FILENAME} +# @return path to the folder where the config is duplicated +function duplicate_config_for_container() { + local OUTPUT_FOLDER + OUTPUT_FOLDER=$(private_config_path "${2}") || return $? + + rm -rf "${OUTPUT_FOLDER:?}/" || return $? # cleanup + mkdir -p "${OUTPUT_FOLDER}" || return $? + cp -r "${PWD}/test/config/${1:?}/." "${OUTPUT_FOLDER}" || return $? + + echo "${OUTPUT_FOLDER}" +} + +# TODO: Should also fail early on "docker logs ${1} | egrep '^[ FATAL ]'"? +# @param ${1} name of the postfix container +function wait_for_finished_setup_in_container() { + local STATUS=0 + repeat_until_success_or_timeout --fatal-test "container_is_running ${1}" "${TEST_TIMEOUT_IN_SECONDS}" sh -c "docker logs ${1} | grep 'is up and running'" || STATUS=1 + + if [[ ${STATUS} -eq 1 ]]; then + echo "Last ${NUMBER_OF_LOG_LINES} lines of container \`${1}\`'s log" + docker logs "${1}" | tail -n "${NUMBER_OF_LOG_LINES}" + fi + + return ${STATUS} +} + +# Common defaults appropriate for most tests, override vars in each test when necessary. +# For all tests override in `setup_file()` via an `export` var. +# For individual test override the var via `local` var instead. +# +# For example, if you need an immutable config volume that can't be affected by other tests +# in the file, then use `local TEST_TMP_CONFIG=$(duplicate_config_for_container . "${UNIQUE_ID_HERE}")` +function init_with_defaults() { + __initialize_variables + + export TEST_TMP_CONFIG + + # In `setup_file()` the default name to use for the currently tested docker container + # is `${CONTAINER_NAME}` global defined here. It derives the name from the test filename: + # `basename` to ignore absolute dir path and file extension, only extract filename. + # In `setup_file()` creates a single copy of the test config folder to use for an entire test file: + TEST_TMP_CONFIG=$(duplicate_config_for_container . "${CONTAINER_NAME}") + + # Common complimentary test files, read-only safe to share across containers: + export TEST_FILES_CONTAINER_PATH='/tmp/docker-mailserver-test' + export TEST_FILES_VOLUME="${REPOSITORY_ROOT}/test/test-files:${TEST_FILES_CONTAINER_PATH}:ro" + + # The config volume cannot be read-only as some data needs to be written at container startup + # - two sed failures (unknown lines) + # - dovecot-quotas.cf (setup-stack.sh:_setup_dovecot_quotas) + # - postfix-aliases.cf (setup-stack.sh:_setup_postfix_aliases) + # TODO: Check how many tests need write access. Consider using `docker create` + `docker cp` for easier cleanup. + export TEST_CONFIG_VOLUME="${TEST_TMP_CONFIG}:/tmp/docker-mailserver" + + # Default Root CA cert used in TLS tests with `openssl` commands: + export TEST_CA_CERT="${TEST_FILES_CONTAINER_PATH}/ssl/example.test/with_ca/ecdsa/ca-cert.ecdsa.pem" +} + +# Using `create` and `start` instead of only `run` allows to modify +# the container prior to starting it. Otherwise use this combined method. +# NOTE: Forwards all args to the create method at present. +function common_container_setup() { + common_container_create "${@}" + common_container_start +} + +# Common docker setup is centralized here. +# +# `X_EXTRA_ARGS` - Optional: Pass an array by it's variable name as a string, it will +# be used as a reference for appending extra config into the `docker create` below: +# +# NOTE: Using array reference for a single input parameter, as this method is still +# under development while adapting tests to it and requirements it must serve (eg: support base config matrix in CI) +function common_container_create() { + [[ -n ${1} ]] && local -n X_EXTRA_ARGS=${1} + + run docker create \ + --tty \ + --name "${CONTAINER_NAME}" \ + --hostname "${TEST_FQDN:-mail.my-domain.com}" \ + --volume "${TEST_FILES_VOLUME}" \ + --volume "${TEST_CONFIG_VOLUME}" \ + --env ENABLE_AMAVIS=0 \ + --env ENABLE_CLAMAV=0 \ + --env ENABLE_UPDATE_CHECK=0 \ + --env ENABLE_SPAMASSASSIN=0 \ + --env ENABLE_FAIL2BAN=0 \ + --env LOG_LEVEL=debug \ + "${X_EXTRA_ARGS[@]}" \ + "${IMAGE_NAME}" + + assert_success +} + +function common_container_start() { + run docker start "${CONTAINER_NAME}" + assert_success + + wait_for_finished_setup_in_container "${CONTAINER_NAME}" +}