diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh new file mode 100755 index 0000000..3366469 --- /dev/null +++ b/.github/behat/pebble-setup.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# Redirects EasyEngine's hardcoded Let's Encrypt endpoint to a local Pebble on a throwaway CI runner, so the @le Behat +# scenarios run without public DNS or real certificates. Test-only: no product code changes. +# +# pebble-setup.sh setup start Pebble + DNS, redirect and trust, block real LE, then run `verify` +# pebble-setup.sh block lanes without Pebble: point the LE host at a blackhole and block real LE +# pebble-setup.sh verify prove EE's HTTP stack reaches Pebble (and cannot reach real LE) +# pebble-setup.sh reach after the EE proxy is up: prove Pebble's VA can fetch http:/// (HTTP-01 path) +# pebble-setup.sh logs print the Pebble, proxy and DNS logs +# pebble-setup.sh teardown undo everything setup or block did +# +# Needs root, docker, openssl, curl, php, iptables and systemd-resolved (GitHub's ubuntu runners have all of them). +# Pebble's root is trusted system-wide while it runs, so never leave it set up on a machine used for anything else. +set -euo pipefail + +PEBBLE_DIR=${PEBBLE_DIR:-/tmp/ee-pebble} +# 2.10+ also offers the token-less draft dns-persist-01 challenge, which EE's acmephp 1.3 client can't parse. +PEBBLE_VERSION=${PEBBLE_VERSION:-2.9.0} +PEBBLE_NET=${PEBBLE_NET:-ee-pebble} +PEBBLE_BRIDGE=${PEBBLE_BRIDGE:-ee-pebble0} +PEBBLE_SUBNET=${PEBBLE_SUBNET:-172.30.0.0/24} +PEBBLE_GATEWAY=${PEBBLE_GATEWAY:-172.30.0.1} +# EE's host resolves to the front proxy: Pebble serves its directory at /dir, EE requests /directory. +PEBBLE_IP=${PEBBLE_IP:-172.30.0.10} +PEBBLE_ACME_IP=${PEBBLE_ACME_IP:-172.30.0.12} +PEBBLE_PROXY_IMAGE=${PEBBLE_PROXY_IMAGE:-nginx:1.29-alpine} +PEBBLE_DNS_IP=${PEBBLE_DNS_IP:-172.30.0.11} +PEBBLE_DNS_IMAGE=${PEBBLE_DNS_IMAGE:-alpine:3.22} +# Every name under this domain resolves to PEBBLE_TARGET_IP, on the runner and for Pebble's VA. +PEBBLE_DOMAIN=${PEBBLE_DOMAIN:-ee4-test.rt.gw} +# The gateway is a host address, so both the runner and the VA reach nginx-proxy's published :80 through it. +PEBBLE_TARGET_IP=${PEBBLE_TARGET_IP:-$PEBBLE_GATEWAY} +ACME_HOST=acme-v02.api.letsencrypt.org +TOS_MARKER='Do%20what%20thou%20wilt' +# TEST-NET-1: never routed, and rejected locally so a request fails at once instead of timing out. +BLACKHOLE_IP=${BLACKHOLE_IP:-192.0.2.1} +PUBLIC_PROBE=api.wordpress.org +MARK='# ee-pebble' +RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/ee-pebble.conf +CA_LINK=/usr/local/share/ca-certificates/ee-pebble-ca.crt +ROOT_LINK=/usr/local/share/ca-certificates/ee-pebble-root.crt +STATE="$PEBBLE_DIR/state" + +log() { printf '[pebble] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +need_root() { [[ $EUID -eq 0 ]] || die 'run as root'; } + +# Records an undo command (one per line) for teardown. +remember() { printf '%s\n' "$*" >>"$STATE"; } + +public_ips() { + # $1 = ahostsv4|ahostsv6, $2 = name; resolved before any override is installed. + getent "$1" "$2" 2>/dev/null | awk '{print $1}' | sort -u || true +} + +block_ip() { + local ip=$1 tool=iptables + [[ $ip == *:* ]] && tool=ip6tables + command -v "$tool" >/dev/null || return 0 + "$tool" -I OUTPUT -d "$ip" -j REJECT + remember "$tool -D OUTPUT -d $ip -j REJECT" + if "$tool" -n -L DOCKER-USER >/dev/null 2>&1; then + "$tool" -I DOCKER-USER -d "$ip" -j REJECT + remember "$tool -D DOCKER-USER -d $ip -j REJECT" + fi + log "blocked egress to $ip" +} + +make_certs() { + local d=$PEBBLE_DIR + openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj '/CN=EE Behat Pebble listener CA' \ + -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign,cRLSign' \ + -keyout "$d/ca.key" -out "$d/ca.pem" 2>/dev/null + openssl req -newkey rsa:2048 -nodes -subj "/CN=$ACME_HOST" -keyout "$d/key.pem" -out "$d/cert.csr" 2>/dev/null + printf 'subjectAltName=DNS:%s,IP:%s\nextendedKeyUsage=serverAuth\nbasicConstraints=critical,CA:FALSE\n' \ + "$ACME_HOST" "$PEBBLE_IP,IP:$PEBBLE_ACME_IP" >"$d/cert.ext" + openssl x509 -req -in "$d/cert.csr" -CA "$d/ca.pem" -CAkey "$d/ca.key" -CAcreateserial -days 2 \ + -extfile "$d/cert.ext" -out "$d/cert.pem" 2>/dev/null + rm -f "$d/ca.key" "$d/cert.csr" "$d/ca.srl" + # Pebble runs as root in a scratch image; the key only has to be readable by it. + chmod 644 "$d/key.pem" +} + +write_config() { + # The blocklist mirrors LE refusing reserved TLDs. + cat >"$PEBBLE_DIR/pebble-config.json" <<-EOF + { + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "/pebble/cert.pem", + "privateKey": "/pebble/key.pem", + "httpPort": 80, + "tlsPort": 443, + "ocspResponderURL": "", + "externalAccountBindingRequired": false, + "domainBlocklist": ["invalid", "test", "example", "localhost", "local"], + "retryAfter": { "authz": 3, "order": 5 }, + "keyAlgorithm": "ecdsa", + "profiles": { + "default": { "description": "LE classic: 90 days, first name as CN", "validityPeriod": 7776000, "promoteCommonName": true } + } + } + } + EOF +} + +write_front_config() { + # Pebble builds its URLs from Host and X-Forwarded-Proto, so they come back as https://$ACME_HOST/... + cat >"$PEBBLE_DIR/front.conf" <<-EOF + server { + listen 443 ssl; + ssl_certificate /pebble/cert.pem; + ssl_certificate_key /pebble/key.pem; + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_redirect off; + proxy_ssl_server_name on; + proxy_ssl_name $ACME_HOST; + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /pebble/ca.pem; + location = /directory { proxy_pass https://$PEBBLE_ACME_IP:14000/dir; } + # Boulder puts a blank line between chain PEMs and acmephp splits on it; Pebble puts none. + location /certZ/ { + proxy_pass https://$PEBBLE_ACME_IP:14000; + # A location-level proxy_set_header drops the inherited ones; Pebble checks the kid against Host. + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Accept-Encoding ""; + sub_filter_types application/pem-certificate-chain; + sub_filter_once off; + sub_filter "-----END CERTIFICATE----- + -----BEGIN CERTIFICATE-----" "-----END CERTIFICATE----- + + -----BEGIN CERTIFICATE-----"; + } + location / { proxy_pass https://$PEBBLE_ACME_IP:14000; } + } + EOF +} + +wait_for() { + # $1 = description, rest = command; up to 60 s. + local what=$1 i + shift + for ((i = 0; i < 60; i++)); do + "$@" >/dev/null 2>&1 && return 0 + sleep 1 + done + die "timed out waiting for $what" +} + +start_containers() { + docker network create --driver bridge --subnet "$PEBBLE_SUBNET" --gateway "$PEBBLE_GATEWAY" \ + -o "com.docker.network.bridge.name=$PEBBLE_BRIDGE" "$PEBBLE_NET" >/dev/null + remember "docker network rm $PEBBLE_NET" + # dnsmasq, not challtestsrv: it answers only the test domain and forwards everything else, because resolved's + # global DNS also ends up in the containers' resolv.conf (challtestsrv answers every name, even wordpress.org). + local upstream + upstream=$(awk '$1 == "nameserver" && $2 !~ /^127\./ {print $2; exit}' /run/systemd/resolve/resolv.conf 2>/dev/null) + [[ -n $upstream ]] || die 'no upstream DNS server found in /run/systemd/resolve/resolv.conf' + docker run -d --name ee-pebble-dns --network "$PEBBLE_NET" --ip "$PEBBLE_DNS_IP" "$PEBBLE_DNS_IMAGE" \ + sh -c "apk add -q --no-cache dnsmasq && exec dnsmasq -k --no-hosts --no-resolv --log-queries --log-facility=- \ + --server=$upstream --address=/$PEBBLE_DOMAIN/$PEBBLE_TARGET_IP" >/dev/null + remember 'docker rm -f ee-pebble-dns' + docker run -d --name ee-pebble --network "$PEBBLE_NET" --ip "$PEBBLE_ACME_IP" \ + -e PEBBLE_VA_NOSLEEP=1 -e PEBBLE_WFE_NONCEREJECT=0 -e PEBBLE_AUTHZREUSE=100 \ + -v "$PEBBLE_DIR:/pebble:ro" "ghcr.io/letsencrypt/pebble:$PEBBLE_VERSION" \ + -config /pebble/pebble-config.json -dnsserver "$PEBBLE_DNS_IP:53" >/dev/null + remember 'docker rm -f ee-pebble' + docker run -d --name ee-pebble-front --network "$PEBBLE_NET" --ip "$PEBBLE_IP" \ + -v "$PEBBLE_DIR:/pebble:ro" -v "$PEBBLE_DIR/front.conf:/etc/nginx/conf.d/default.conf:ro" \ + "$PEBBLE_PROXY_IMAGE" >/dev/null + remember 'docker rm -f ee-pebble-front' +} + +allow_va_forwarding() { + # Docker's inter-network isolation may drop the VA's DNATed request to nginx-proxy's bridge; DOCKER-USER runs first. + iptables -n -L DOCKER-USER >/dev/null 2>&1 || return 0 + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" + iptables -I DOCKER-USER -i "$PEBBLE_BRIDGE" -p tcp --dport 80 -j ACCEPT + remember "iptables -D DOCKER-USER -i $PEBBLE_BRIDGE -p tcp --dport 80 -j ACCEPT" + # Site containers get the resolver from resolved's resolv.conf, which now lists the Pebble DNS. + local proto + for proto in udp tcp; do + iptables -I DOCKER-USER -o "$PEBBLE_BRIDGE" -d "$PEBBLE_DNS_IP" -p "$proto" --dport 53 -j ACCEPT + remember "iptables -D DOCKER-USER -o $PEBBLE_BRIDGE -d $PEBBLE_DNS_IP -p $proto --dport 53 -j ACCEPT" + done +} + +redirect_acme_host() { + cp /etc/hosts "$PEBBLE_DIR/hosts.bak" + printf '%s\t%s %s\n' "$PEBBLE_IP" "$ACME_HOST" "$MARK" >>/etc/hosts + remember "sed -i '/ $MARK\$/d' /etc/hosts" +} + +route_test_domain() { + systemctl is-active --quiet systemd-resolved || die 'systemd-resolved is not active; cannot route *.'"$PEBBLE_DOMAIN" + mkdir -p "$(dirname "$RESOLVED_DROPIN")" + printf '[Resolve]\nDNS=%s\nDomains=~%s\n' "$PEBBLE_DNS_IP" "$PEBBLE_DOMAIN" >"$RESOLVED_DROPIN" + remember "rm -f $RESOLVED_DROPIN; systemctl restart systemd-resolved" + systemctl restart systemd-resolved + resolvectl flush-caches 2>/dev/null || true +} + +php_cafiles() { + # CA files PHP is configured to use instead of the system bundle, if any. + php <<-'PHP' | sort -u + /dev/null" + update-ca-certificates >/dev/null + # Guzzle (verify=true) and PHP streams fall back to these when set, so they must trust the CAs too. + while IFS= read -r f; do + [[ -f $f ]] || continue + [[ $(readlink -f "$f") == "$(readlink -f /etc/ssl/certs/ca-certificates.crt)" ]] && continue + cp "$f" "$f.ee-pebble.bak" + remember "mv -f '$f.ee-pebble.bak' '$f'" + for pem in "$PEBBLE_DIR/ca.pem" "$PEBBLE_DIR/pebble-root.pem"; do cat "$pem" >>"$f"; done + log "appended the CAs to PHP's $f" + done < <(php_cafiles) +} + +write_env() { + local issuer + issuer=$(openssl x509 -in "$PEBBLE_DIR/pebble-intermediate.pem" -noout -subject -nameopt multiline | + sed -n 's/^ *commonName *= *//p') + [[ -n $issuer ]] || die 'could not read the Pebble intermediate CN' + printf 'EE_BEHAT_LE_ISSUER=%s\nEE_BEHAT_LE_DNS_SERVER=127.0.0.53\nEE_BEHAT_PEBBLE_ROOT=%s\n' \ + "$issuer" "$PEBBLE_DIR/pebble-root.pem" >"$PEBBLE_DIR/env" + if [[ -n ${GITHUB_ENV:-} ]]; then + cat "$PEBBLE_DIR/env" >>"$GITHUB_ENV" + fi + log "issuer for the scenarios: $issuer" +} + +cmd_setup() { + need_root + [[ ! -e $STATE ]] || die "$STATE exists; run teardown first" + mkdir -p "$PEBBLE_DIR" + : >"$STATE" + + local ip + # Resolve before the overrides so the real addresses get blocked. + for ip in $(public_ips ahostsv4 "$ACME_HOST") $(public_ips ahostsv6 "$ACME_HOST") \ + $(public_ips ahostsv4 "$PEBBLE_DOMAIN") $(public_ips ahostsv6 "$PEBBLE_DOMAIN"); do + block_ip "$ip" + done + + make_certs + write_config + write_front_config + start_containers + allow_va_forwarding + redirect_acme_host + + wait_for 'the Pebble directory' curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$ACME_HOST/directory" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/roots/0" -o "$PEBBLE_DIR/pebble-root.pem" + curl -fsS --cacert "$PEBBLE_DIR/ca.pem" "https://$PEBBLE_ACME_IP:15000/intermediates/0" -o "$PEBBLE_DIR/pebble-intermediate.pem" + + trust_cas + wait_for 'the Pebble DNS' docker exec ee-pebble-dns pidof dnsmasq + route_test_domain + write_env + cmd_verify + log 'setup done' +} + +cmd_verify() { + local probe resolved ip body + resolved=$(getent ahostsv4 "$ACME_HOST" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_IP" ]] || die "$ACME_HOST resolves to '$resolved', not Pebble ($PEBBLE_IP)" + + body=$(curl -fsS "https://$ACME_HOST/directory") || die 'curl with the system trust store cannot reach Pebble' + [[ $body == *"$TOS_MARKER"* ]] || die "the directory at $ACME_HOST is not Pebble's: $body" + [[ $body == *"https://$ACME_HOST/"* ]] || die "Pebble's directory does not use $ACME_HOST in its URLs: $body" + + # The same two paths EE's ACME client can take: Guzzle over ext-curl (verify=true, no CA option) and PHP streams. + # Pebble rejects requests without a User-Agent (Guzzle always sends one). + ACME_HOST=$ACME_HOST TOS_MARKER=$TOS_MARKER php <<-'PHP' >&2 || die 'PHP does not reach Pebble with default TLS verification' + true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'ee-pebble-verify']); + $a = curl_exec($c); + if (false === $a || false === strpos($a, $m)) { + fwrite(STDERR, 'php curl: ' . json_encode(['error' => curl_error($c), 'url' => $u, 'marker' => $m, 'code' => curl_getinfo($c, CURLINFO_RESPONSE_CODE), 'ip' => curl_getinfo($c, CURLINFO_PRIMARY_IP), 'body' => substr((string) $a, 0, 300)]) . "\n"); + exit(1); + } + $b = @file_get_contents($u, false, stream_context_create(['http' => ['user_agent' => 'ee-pebble-verify']])); + if (false === $b || false === strpos($b, $m)) { fwrite(STDERR, 'php stream: ' . json_encode([error_get_last(), substr((string) $b, 0, 300)]) . "\n"); exit(1); } + echo 'php curl + stream reach Pebble; ', json_encode(['curl.cainfo' => ini_get('curl.cainfo'), 'openssl' => openssl_get_cert_locations()['default_cert_file'], 'ssl' => curl_version()['ssl_version']]), "\n"; + PHP + + probe="probe-$RANDOM.$PEBBLE_DOMAIN" + resolved=$(getent ahostsv4 "$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + resolved=$(getent ahostsv4 "www.$probe" | awk 'NR==1{print $1}') + [[ $resolved == "$PEBBLE_TARGET_IP" ]] || die "www.$probe resolves to '$resolved', not $PEBBLE_TARGET_IP" + + # Everything else must still resolve normally, on the host and in containers on other networks (like EE's sites). + resolved=$(getent ahostsv4 "$PUBLIC_PROBE" | awk 'NR==1{print $1}') + [[ -n $resolved && $resolved != "$PEBBLE_TARGET_IP" ]] || die "$PUBLIC_PROBE resolves to '$resolved' on the host" + log "resolved upstreams for containers: $(grep '^nameserver' /run/systemd/resolve/resolv.conf | tr '\n' ' ')" + docker network create ee-pebble-probe >/dev/null + body=$(docker run --rm --network ee-pebble-probe "$PEBBLE_DNS_IMAGE" sh -c \ + "nslookup $probe | tail -n +3; wget -q -O /dev/null https://$PUBLIC_PROBE/ && echo public-https-ok" 2>&1) || true + docker network rm ee-pebble-probe >/dev/null + log "container view of $probe: $(tr '\n' ' ' <<<"$body")" + [[ $body == *public-https-ok* ]] || die "containers can no longer reach https://$PUBLIC_PROBE/: $body" + + while read -r _ ip; do + [[ $ip == *:* ]] && ip="[$ip]" + if curl -sS --connect-timeout 5 --resolve "$ACME_HOST:443:$ip" -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "real Let's Encrypt at $ip is still reachable" + fi + done < <(grep -E '^(ip6?tables) -D OUTPUT' "$STATE" | awk '{print $1, $5}') + log "verified: $ACME_HOST -> Pebble $PEBBLE_IP (system trust), *.$PEBBLE_DOMAIN -> $PEBBLE_TARGET_IP, real LE blocked" +} + +cmd_block() { + need_root + [[ ! -e $STATE ]] || die "$STATE exists; run teardown first" + mkdir -p "$PEBBLE_DIR" + : >"$STATE" + + local ip resolved + for ip in $(public_ips ahostsv4 "$ACME_HOST") $(public_ips ahostsv6 "$ACME_HOST") "$BLACKHOLE_IP"; do + block_ip "$ip" + done + printf '%s\t%s %s\n' "$BLACKHOLE_IP" "$ACME_HOST" "$MARK" >>/etc/hosts + remember "sed -i '/ $MARK\$/d' /etc/hosts" + + resolved=$(getent ahosts "$ACME_HOST" | awk 'NR==1{print $1}') + [[ $resolved == "$BLACKHOLE_IP" ]] || die "$ACME_HOST resolves to '$resolved', not the blackhole $BLACKHOLE_IP" + if curl -sS --connect-timeout 5 -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "$ACME_HOST is still reachable" + fi + while read -r _ ip; do + [[ $ip == "$BLACKHOLE_IP" ]] && continue + [[ $ip == *:* ]] && ip="[$ip]" + if curl -sS --connect-timeout 5 --resolve "$ACME_HOST:443:$ip" -o /dev/null "https://$ACME_HOST/directory" 2>/dev/null; then + die "real Let's Encrypt at $ip is still reachable" + fi + done < <(grep -E '^(ip6?tables) -D OUTPUT' "$STATE" | awk '{print $1, $5}') + log "verified: $ACME_HOST -> $BLACKHOLE_IP (rejected), real LE blocked" +} + +cmd_reach() { + local code + code=$(docker run --rm --network "$PEBBLE_NET" "curlimages/curl:8.11.1" -sS -o /dev/null -w '%{http_code}' \ + --connect-timeout 10 "http://$PEBBLE_TARGET_IP/.well-known/acme-challenge/ee-pebble-probe") || + die "a container on $PEBBLE_NET cannot reach http://$PEBBLE_TARGET_IP/ (HTTP-01 would fail)" + log "container on $PEBBLE_NET -> http://$PEBBLE_TARGET_IP/ answered HTTP $code" +} + +cmd_logs() { + local c + for c in ee-pebble ee-pebble-front ee-pebble-dns; do + echo "===== $c" + docker logs "$c" 2>&1 || true + done +} + +cmd_teardown() { + need_root + [[ -f $STATE ]] || { log 'nothing to tear down'; return 0; } + local line + # Undo in reverse order; keep going on errors so a partial setup is still cleaned up. + while IFS= read -r line; do + bash -c "$line" >/dev/null 2>&1 || log "undo failed (ignored): $line" + done < <(tac "$STATE") + resolvectl flush-caches 2>/dev/null || true + rm -rf "$PEBBLE_DIR" + log 'teardown done' +} + +case ${1:-} in + setup) cmd_setup ;; + block) cmd_block ;; + verify) cmd_verify ;; + reach) cmd_reach ;; + logs) cmd_logs ;; + teardown) cmd_teardown ;; + *) + sed -n '2,13p' "$0" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 0000000..c7e0154 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,220 @@ +# Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +# The main lane's job is named "Behat Tests - PHP " to match the required checks; update those with the matrix. +name: Behat + +on: + pull_request: + push: + branches: + - develop + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: behat-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REF: develop + COMPOSE_VERSION: v5.5.1 + # Not RCLONE_*: rclone reads those variables as its own flags. + RCLONE_RELEASE: v1.75.1 + # No public DNS (Let's Encrypt) or real backup storage on hosted runners. + EXCLUDE_TAGS: '~@le&&~@remote-real' + # le* lanes run @le against a local Pebble instead (.github/behat/pebble-setup.sh); @le-real needs the real service. + LE_TAGS: '@le&&~@le-real&&~@remote-real' + # Local rclone alias written to EE's rclone-path, for suites with @remote scenarios ('eetest:ee-backups'); empty = no rclone. + EE_RCLONE_PATH: '' + COMPOSE_INTERACTIVE_NO_CLI: 1 + RCLONE_CONFIG: /etc/rclone/rclone.conf + EE_BEHAT_DESTRUCTIVE: 1 + +jobs: + behat: + name: ${{ matrix.lane == 'main' && format('Behat Tests - PHP {0}', matrix.php) || format('Behat {0} (PHP {1})', matrix.lane, matrix.php) }} + runs-on: ubuntu-latest + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + lane: [main, slow, le] + include: + - lane: main + tags: '~@known-bug&&~@slow' + - lane: slow + tags: '@slow&&~@known-bug' + # @le against Pebble (.github/behat/pebble-setup.sh). + - lane: le + tags: '~@known-bug' + steps: + - name: Check out the package + uses: actions/checkout@v7 + with: + path: pkg + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer + extensions: pcntl, curl, sqlite3, zip, dom, mbstring, json + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config -g cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Set up Composer caching + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-php${{ matrix.php }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-composer-php${{ matrix.php }}- + + - name: Prepare the host + run: | + # The runner image ships web and database servers; EE needs 80/443. + sudo systemctl stop nginx apache2 mysql 2>/dev/null || true + sudo ss -ltnp | grep -E ':(80|443|3306) ' || true + sudo curl -fsSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker version --format 'docker {{.Server.Version}}' + docker-compose version + openssl version + echo "umask $(umask), under sudo $(sudo sh -c umask)" + # Suites that request the host's public IP use the first of these; EE picks a frontend subnet outside eth0's. + echo "hostname -I: $(hostname -I)" + ip -4 route + if [ -n "$EE_RCLONE_PATH" ]; then + curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip" + unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP" + sudo install -m 0755 "$RUNNER_TEMP/rclone" /usr/local/bin/rclone + sudo mkdir -p "$(dirname "$RCLONE_CONFIG")" /var/tmp/ee-rclone + printf '[%s]\ntype = alias\nremote = /var/tmp/ee-rclone\n' "${EE_RCLONE_PATH%%:*}" | sudo tee "$RCLONE_CONFIG" >/dev/null + sudo -E rclone listremotes + fi + + # Every lane without Pebble points EE's hardcoded Let's Encrypt host at a blackhole, so no scenario can reach the real service. + - name: Block the real Let's Encrypt + if: ${{ !startsWith(matrix.lane, 'le') }} + run: sudo -E pkg/.github/behat/pebble-setup.sh block + + - name: Set up EasyEngine core + run: | + git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine + cd easyengine + # All easyengine/* packages on dev-develop (as core CI does), except the one under test: that's this checkout. + php -r ' + $pkg = json_decode(file_get_contents("../pkg/composer.json"), true)["name"]; + $j = json_decode(file_get_contents("composer.json"), true); + foreach ($j["require"] as $k => $v) if (strpos($k, "easyengine/") === 0) $j["require"][$k] = "dev-develop"; + $repos = isset($j["repositories"]) ? $j["repositories"] : []; + array_unshift($repos, ["type" => "path", "url" => "../pkg", "options" => ["symlink" => false, "versions" => [$pkg => "dev-develop"]]]); + $j["repositories"] = $repos; + file_put_contents("composer.json", json_encode($j, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + file_put_contents(getenv("GITHUB_ENV"), "PKG=$pkg\n", FILE_APPEND);' + composer update --prefer-dist --no-progress --no-interaction + composer show "$(jq -r .name ../pkg/composer.json)" | grep -E '^(versions|source|dist) ' || true + rm -rf features + cp -R ../pkg/features features + # Some commands (clone, sync) shell out to `ee` from PATH. + sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee + + - name: Initialise EasyEngine + working-directory: easyengine + run: | + sudo -E bin/ee cli info + # The first non-cli command runs the initial setup (DB, config, image pulls). + sudo -E bin/ee site list || true + # Non-LE commands such as ssl-renew still prompt for le-mail when it's missing. + sudo -E bin/ee config set le-mail behat-ci@example.org + if [ -n "$EE_RCLONE_PATH" ]; then + sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" + sudo -E bin/ee config set no-of-backups 3 + fi + # EasyEngine's first run replaces the installed docker-compose with 2.27.0, so that's what the suite runs on, as on a fresh install. + # That download doesn't use curl --fail, so a GitHub error page can replace the binary; fetch 2.27.0 again then. + if ! docker-compose version; then + sudo curl -fsSL --retry 5 "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose version + fi + + # The suites run bin/ee; this keeps the release build (no dev autoloading) covered. + - name: Build the phar + if: matrix.lane == 'main' + working-directory: easyengine + run: | + composer dump-autoload --no-dev -q + php -dphar.readonly=0 utils/make-phar.php easyengine.phar --quiet + composer dump-autoload -q + sudo php easyengine.phar cli version + + - name: Start Pebble and redirect Let's Encrypt to it + if: startsWith(matrix.lane, 'le') + run: | + sudo -E pkg/.github/behat/pebble-setup.sh setup + # Diagnostic only: nginx-proxy may not be up before the first site exists. + sudo docker ps --format '{{.Names}} {{.Ports}}' + sudo pkg/.github/behat/pebble-setup.sh reach || true + + - name: Run Behat + working-directory: easyengine + shell: 'script -q -e -c "bash {0}"' + # A hung step (e.g. a command stopped on the TTY) should still leave logs. + timeout-minutes: 75 + run: | + set -e + mkdir -p "$RUNNER_TEMP/behat/junit" + base="$EXCLUDE_TAGS" + case "${{ matrix.lane }}" in le*) base="$LE_TAGS" ;; esac + behat=(sudo -E vendor/bin/behat --strict --tags="${base}&&${{ matrix.tags }}" + --format=progress --out=std + --format=junit --out="$RUNNER_TEMP/behat/junit" + --format=pretty --out="$RUNNER_TEMP/behat/pretty.log") + "${behat[@]}" + + - name: Collect logs + if: always() + run: | + out="$RUNNER_TEMP/behat" + mkdir -p "$out" + sudo cp /opt/easyengine/logs/ee.log "$out/" 2>/dev/null || echo 'No ee.log.' + sudo stat -c '%a %U:%G %n' /opt/easyengine /opt/easyengine/logs > "$out/dirs.txt" 2>/dev/null || true + sudo docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' > "$out/containers.txt" || true + if [[ "${{ matrix.lane }}" == le* ]]; then + sudo pkg/.github/behat/pebble-setup.sh logs > "$out/pebble.log" 2>&1 || true + tail -n 60 "$out/pebble.log" || true + fi + sudo chown -R "$USER" "$out" + # One block per failed scenario, from the junit files. + python3 - "$out" <<'PY' + import glob, re, sys, xml.etree.ElementTree as ET + out = sys.argv[1] + lines = [] + for f in sorted(glob.glob(out + '/junit/*.xml')): + # Behat copies raw output (docker logs with ANSI escapes) into the junit file; XML 1.0 forbids those bytes. + data = re.sub(rb'[\x00-\x08\x0b\x0c\x0e-\x1f]', b'', open(f, 'rb').read()) + for case in ET.fromstring(data).iter('testcase'): + for bad in list(case.findall('failure')) + list(case.findall('error')): + lines.append('### %s :: %s\n%s\n%s\n' % (case.get('classname'), case.get('name'), bad.get('message') or '', bad.text or '')) + open(out + '/failures.txt', 'w').write('\n'.join(lines) or 'No failures.\n') + print('%d failed scenarios' % len(lines)) + PY + head -c 20000 "$out/failures.txt" + + - name: Tear down Pebble and the Let's Encrypt block + if: always() + run: sudo pkg/.github/behat/pebble-setup.sh teardown + + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: behat-${{ matrix.lane }}-php${{ matrix.php }} + path: ${{ runner.temp }}/behat + retention-days: 14 diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml deleted file mode 100644 index f8fce96..0000000 --- a/.github/workflows/test_and_build.yml +++ /dev/null @@ -1,94 +0,0 @@ -on: - pull_request: - push: - branches: - - master - - develop - -name: Test 👨‍🔧 - -jobs: - - test: #---------------------------------------------------------------------- - runs-on: ubuntu-latest - name: Behat Tests - PHP ${{ matrix.php }} - strategy: - fail-fast: false - matrix: - php: ['7.4', '8.0'] - steps: - - name: Check out source code - uses: actions/checkout@v2 - - - name: Get Composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Set up Composer caching - uses: actions/cache@v4 - env: - cache-name: cache-composer-dependencies - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '${{ matrix.php }}' - coverage: none - tools: composer - extensions: pcntl, curl, sqlite3, zip, dom, mbstring, json - - - name: Update docker - run: | - sudo apt purge nginx nginx-common docker docker-engine docker.io docker-ce containerd runc - curl -fsSL https://get.docker.com/ | sudo bash - sudo systemctl restart docker.service - - - name: Install docker-compose - run: | - VERSION=$(curl --silent "https://api.github.com/repos/docker/compose/releases/latest" | - grep '"tag_name":' | - sed -E 's/.*"([^"]+)".*/\1/' - ) - sudo curl -L "https://github.com/docker/compose/releases/download/$VERSION/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - sudo chmod +x /usr/local/bin/docker-compose - - - name: Install dependencies - run: | - cd "$GITHUB_WORKSPACE/.." - git clone https://github.com/EasyEngine/easyengine.git easyengine --depth=1 - cd easyengine - rm -rf features - cp -R $GITHUB_WORKSPACE/features . - # Run composer install for master else update. - if [[ "$GITHUB_REF" = "refs/heads/master" ]]; then - composer install --prefer-dist --no-progress --no-interaction - else - sed -i 's/\(easyengine\/.*\):\ \".*\"/\1:\ \"dev-develop\"/' composer.json - composer update --prefer-dist --no-progress --no-interaction - fi - composer dump-autoload --no-dev - php -dphar.readonly=0 ./utils/make-phar.php easyengine.phar --quite > /dev/null - composer dump-autoload - sudo php easyengine.phar cli info - - - name: Test - shell: 'script -q -e -c "bash {0}"' - run: | - set -e - cd "$GITHUB_WORKSPACE/../easyengine" - sudo -E ./vendor/bin/behat - env: - COMPOSE_INTERACTIVE_NO_CLI: 1 - - - name: Output logs - if: ${{ always() }} - run: | - # The logs directory is readable by root only. - sudo test -f /opt/easyengine/logs/install.log && sudo cat /opt/easyengine/logs/install.log || echo 'No install log.' - sudo test -f /opt/easyengine/logs/ee.log && sudo cat /opt/easyengine/logs/ee.log || echo 'No run log.' - diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 0000000..431c785 --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,174 @@ +# site-type-php Behat coverage + +Scope: `ee site create --type=php` and everything php-specific (`src/PHP.php`, `src/Site_PHP_Docker.php`, `templates/`), plus the site-command subcommands as they behave on a php site (the php class supplies `dump_docker_compose_yml()`, so update/enable/refresh output is php-specific). Generic site-command internals (LE renewal, backup/restore, clone, share) belong to the site-command suite. + +## Inventory + +### `ee site create --type=php` (`PHP::create`) + +| Item | Covered by | +|---|---| +| `` required (usage on missing) | site.feature "php site create without a site name prints the php usage" | +| name normalisation: `http://` stripped, trailing `/` stripped, lowercased (`https://` shares the code path, not tested) | create.feature "The site name is normalised" | +| name without a dot gets `.test` | create.feature "A site name without a dot gets the .test TLD" | +| duplicate site name refused, first site untouched | create.feature "Creating the same site twice fails" | +| site name validation | create.feature "A site name with shell syntax is rejected" `@security`, regression check for the v4.13.2 fix (EasyEngine/site-type-php#117, EasyEngine/site-command#509) | +| unknown flag refused | create.feature "An unknown flag is rejected" | +| unknown `--type` refused | site.feature "An unknown site type is rejected" | +| `--cache` (global redis) | cache.feature scenarios 1-4 (DB flags, main.conf, networks, HIT from redis, BYPASS on query string) | +| `--with-local-redis` (with `--cache`) | cache.feature "--cache --with-local-redis", "Pages of a local-redis site", "Local redis and global db", "Local db and local redis" | +| `--with-local-redis` without `--cache` (ignored) | cache.feature "--with-local-redis without --cache enables no cache" | +| local redis restart policy | not covered yet: the local redis service has no restart policy, unlike every other service (its scenario comes back with the fix) | +| `--admin-email` (and default `admin@`) | create.feature "--admin-email is stored and shown", "The site row holds the php defaults" | +| `--with-db` global db (DB/user created, creds stored, php connects) | database.feature scenarios 1-2 | +| `--dbname` / `--dbuser` / `--dbpass` on global db | database.feature "--dbname, --dbuser and --dbpass are used for the global db" | +| `--dbhost=global-db` | database.feature "--dbhost=global-db is the same as the default global db" | +| `--local-db` (db container, env, volumes, root password, php connects) | database.feature "--local-db runs a MariaDB container", "A local-db php site has its db service" | +| `--local-db` without `--with-db` (ignored) | database.feature "--local-db without --with-db creates no database at all" | +| remote `--dbhost=host:port` with credentials | database.feature "A remote --dbhost with host:port and existing credentials" (uses `global-db:3306` as the "remote") | +| remote `--dbhost` needs `--dbuser` and `--dbpass` | database.feature "A remote --dbhost needs --dbuser and --dbpass" | +| `--skip-check` | database.feature "--skip-check creates the site without probing the remote db" (only proves the create and port parsing: nothing probes a remote db today) | +| remote DB verification / DB auto-create / non-empty DB check | not covered yet: remote credentials are never checked, so an unreachable remote db host doesn't fail the create (its scenario comes back with the fix); DB auto-create and the non-empty-DB check: not covered, need a MariaDB reachable from the default bridge network (the verify container), which the test host doesn't provide | +| `--force` (reset non-empty remote DB) | not covered: dead code today (remote credentials are never checked), needs a reachable remote MariaDB | +| DB name / user collisions on rollback | database.feature: `--dbname`/`--dbuser` of another site refused, colliding default names refused by the volume check, a taken default name gets a `_2` suffix, each on its own sites (regression checks for EasyEngine/site-command#503 and EasyEngine/site-type-php#115) | +| mysql CLI in the php container against the global and local db | not covered yet: the MariaDB client in php8.2+ images demands TLS, which the EasyEngine MariaDB doesn't offer (its 2 scenarios come back with the fix) | +| `--php=` every supported version + `latest` | php-version.feature outline (12 examples, `@slow`) | +| `--php` default 8.4 | create.feature "The site row holds the php defaults", "The compose file ..." | +| `--php` invalid values | create.feature outline "An unsupported --php value is rejected" (4 examples) | +| `--php` loosely matching values (`8`, `7`, `8.40`, `8.4.1`) | not covered yet: a loose `in_array()` accepts them and the create fails late on a missing image (its outline, 4 examples, comes back with the fix) | +| `--php=` without `--type` (EE3 compat mapping) | not covered yet: the version is consumed as the EE3 `--php` type flag and dropped (its scenario comes back with the fix) | +| EE3 `--mysql` compat flag | create.feature "The legacy --mysql flag creates a php site with a database" | +| php 5.6 ini (sendmail) vs msmtp | php-version.feature outline `sendmail` column | +| `--alias-domains` plain + wildcard (DB, compose VIRTUAL_HOST/CERT_NAME, main.conf server_name, HTTP) | alias-domains.feature scenarios 1-3 | +| alias owned by another site / site name that is an alias | alias-domains.feature scenarios 4-5 | +| alias trimming | alias-domains.feature "Spaces around alias domains are trimmed" | +| alias that is an existing site | not covered yet: create checks aliases only against other sites' aliases, not their names (its scenario comes back with the fix) | +| blank alias entries | alias-domains.feature `@destructive` regression (fixed by EasyEngine/site-type-php#114; a regression breaks the nginx-proxy config until the rollback) | +| invalid alias names (e.g. `_wildcard.`, which could overwrite proxy auth files) | alias-domains.feature outline, regression (fixed by EasyEngine/site-type-php#114; 3 examples, the one with a space is also `@destructive`) | +| `--public-dir` (relative, full path, nested, traversal sanitised, with cache, outside files not served) | public-dir.feature | +| bare `--public-dir` | not covered yet: a bare `--public-dir` becomes the document root `1` (its scenario comes back with the fix) | +| `--skip-status-check` | create.feature "--admin-email is stored and shown, --skip-status-check skips the HTTP probe" | +| `--ssl=self` (+ wildcard flag stored, HTTPS, HTTP->HTTPS redirect, cert SANs) | ssl.feature scenarios 1-2 | +| `--ssl=custom --ssl-key --ssl-crt` | ssl.feature "Create a php site with a custom certificate" | +| custom SSL argument errors (missing key/crt, missing file) | ssl.feature 2 scenarios | +| `--ssl=inherit` (no parent, parent with wildcard, with `--wildcard`) | ssl.feature 3 scenarios | +| `--ssl=` (rejected only after the site is built) | ssl.feature "--ssl with an unknown value fails and rolls back" | +| `--ssl=le` | ssl.feature `@le` (create, verified HTTPS, issuer, delete + revoke) | +| `--wildcard` with `--ssl=le` | not covered: needs Cloudflare DNS-01 credentials (not configured here) | +| rollback (`catch_clean`) leaves no site row, dir, volumes, containers or network | ssl.feature "--ssl with an unknown value" and "--ssl=inherit together with --wildcard" (real level-4 rollbacks); the other failing creates stop in validation and assert the row and dir only | +| www redirect (non-www and www sites), no redirect without resolvable www | redirect.feature | + +### Generated files, containers and side effects of create + +| Item | Covered by | +|---|---| +| sites row (type, sub type, php version, paths, alias list, cache flags, SSL fields, DB fields) | create.feature "The site row holds the php defaults", database/cache/ssl features | +| `docker-compose.yml` (pinned images from img-versions.json, services, env, php network alias, no db/redis unless asked, nohttps on http sites) | create.feature "The compose file has the php services and no db or redis", cache/database/php-version features | +| `.env`, `main.conf` (upstream, root, server_name, php vs redis blocks), `custom.ini`, `user.conf`, `admin-tools.conf`, `index.php`, `app/html` removed, `app` symlinked to the htdocs volume, `check.html` removed | create.feature "The site files are generated from the php templates" | +| running containers (php, nginx, postfix; mailhog not started; + db/redis), labels, restart policy, networks | create.feature "The site containers, volumes and network are up", labels.feature, database/cache features | +| docker volumes (8, or 11 with local db) and network labels | create.feature, labels.feature, database.feature | +| volume prefix collision between names differing only in `.`/`-` | create.feature "A site name whose docker volumes collide with an existing site is refused" (regression check for EasyEngine/site-command#503) | +| `/etc/hosts` entry (http and self-signed only) | create.feature, ssl.feature, alias-domains.feature | +| HTTP: 200 + PHP executes, pretty URLs, 404, hidden/backup files denied, fpm ping/status, nginx_status | create.feature "The site is reachable over HTTP and runs PHP", "nginx denies hidden and backup files", "The php-fpm status and ping endpoints are wired" | + +### `ee site info [] [--format=table|json]` (`PHP::info`) + +| Item | Covered by | +|---|---| +| table rows: Site, Site Root, DB Host/Name/User/Password, DB Root Password (local db only), Alias Domains, E-Mail, SSL, SSL Wildcard, Cache | lifecycle.feature 3 info scenarios, create/database/ssl/cache/alias create outputs | +| `--format=json` | lifecycle.feature "site info --format=json dumps the site row" (+ db site) | +| site auto-detected from the current directory | lifecycle.feature "site info works from inside the site directory" (the site root; `app/` is a symlink into the volume, so ee's physical `pwd` has no site name there) | +| disabled site | lifecycle.feature "site info still works for a disabled php site" | +| missing / unknown site | site.feature "Check site info sub command is present", lifecycle.feature "site info of an unknown site fails" | +| "Access admin-tools" row | not covered: admin-tools-command's suite owns enabling admin tools | + +### `ee site restart [] [--all] [--nginx] [--php] [--db]` (`PHP::restart`) + +| Item | Covered by | +|---|---| +| no flag: nginx + php + local db restarted, postfix not | lifecycle.feature "site restart restarts nginx, php and the local db, not postfix" | +| `--php` only | lifecycle.feature "site restart --php restarts only php" | +| `--db` with local db | lifecycle.feature "site restart --db restarts the local db" | +| `--db` without local db | not covered yet: the compose exit code is ignored, so restarting a service the site doesn't have still exits 0 (its scenario comes back with the fix) | +| `--all`, `--nginx` alone | not covered separately (same code path as no flag / `--php`) | + +### `ee site reload [] [--all] [--nginx] [--php]` (`PHP::reload`) + +| Item | Covered by | +|---|---| +| no flag: nginx + php-fpm (USR2) reloaded, containers not restarted | lifecycle.feature "site reload reloads nginx and php-fpm without restarting containers" | +| `--php`, `--nginx` | lifecycle.feature "site reload --php and --nginx reload a single service" | +| disabled site refused | lifecycle.feature "site reload of a disabled php site is refused" | +| missing site | site.feature "Check site reload sub command is present" | +| `--all` | not covered separately (same path as no flag) | + +### site-command subcommands on a php site + +| Item | Covered by | +|---|---| +| `update --php` (compose image, container version, backup dir, custom.ini kept, 5.6 <-> msmtp/sendmail) | php-version.feature 3 update scenarios | +| `update --php` same version / invalid / `latest` / disabled / unknown site | php-version.feature 4 scenarios | +| `update --add-alias-domains` / `--delete-alias-domains` (+ all error paths, wildcard) | alias-domains.feature 9 update scenarios | +| `update --ssl=self` / `--ssl=off` / already enabled / already disabled / `--ssl=off --wildcard` (on a wildcard site) / normal to wildcard | ssl.feature 3 scenarios | +| `update --ssl=off` on a site created with `--ssl=self` | ssl.feature "SSL of a site created with --ssl=self can be disabled" (regression check, fixed by EasyEngine/site-command#505) | +| `update --ssl=le`, `--wildcard` | not covered (LE budget; wildcard needs DNS-01) | +| `update --proxy-cache=on/off` (conf.d, vhost.d incl. alias, already-on error) | lifecycle.feature `@destructive` (restarts nginx-proxy) | +| `--proxy-cache-max-size/-max-time/-key-zone-size` | not covered: site-command generic | +| `enable` (containers, post-enable, already enabled, `--force`) | lifecycle.feature 2 scenarios | +| `refresh` | lifecycle.feature "site refresh regenerates the docker-compose file" | +| `enable --refresh` | not covered separately (same regeneration) | +| `enable --verify` | not covered: site-command generic | +| `disable` (containers removed, volumes/files kept, HTTP down, idempotent) | lifecycle.feature 2 scenarios | +| `list` (`--format=json/text`, `--enabled`, `--disabled`) | lifecycle.feature 2 scenarios | +| `delete` (row, dir, containers, volumes, network, hosts; redirect conf; global DB + user dropped; local db volumes) | lifecycle.feature "Delete a php site and everything it owns", redirect.feature "Deleting a site removes its redirect config" (asserts the conf existed first), database.feature 2 delete scenarios | +| `delete` twice / unknown site | lifecycle.feature "Deleting a php site twice fails the second time" | +| `delete` of an LE site removes certs | ssl.feature `@le` ("Revoking certificate." is logged before the attempt and revocation errors are swallowed, so revocation itself is not proven) | +| `clean` without cache / `--proxy` without proxy cache | cache.feature "ee site clean on a site without cache" | +| `clean --page` on a php cache site | not covered yet: clean purges `_page*` but php sites store pages under `:http...`, so nothing is purged (its scenario comes back with the fix) | +| `clean` on a local-redis site | not covered yet: clean always targets the global redis, never the site's local redis (its scenario comes back with the fix) | +| `clean --object`, `clean --proxy` with proxy cache | not covered: `--object` shares the `clean --page` path, which purges the wrong keys; `--proxy` runs `docker exec -it`, which fails without a TTY | +| `ssl-verify`, `ssl-info`, `ssl-renew`, `share`, `clone`, `sync`, `backup`, `restore` | not covered here: generic, owned by the site-command suite (no php-specific code) | +| `site_cleanup` hook on delete | indirectly (delete scenarios); hook consumers are tested in their packages | + +Estimated coverage: about 88% of the inventory items above (php-specific items: about 95%; the gaps are the remote-DB verification paths that need a reachable remote MariaDB, `--force`, LE wildcard, and flags that share code paths with covered ones). Several items counted before only passed vacuously (`--ssl=off --wildcard`, the directory auto-detect, rollback, the redirect delete, the combo cache checks); they now assert the real behaviour. + +## Scenarios + +146 scenarios (outline examples included): 133 regular, 12 `@slow`, 1 `@le`. The CI workflow runs them in three lanes: main (`~@slow`), slow and le (against Pebble). + +## Known bugs left out + +Scenarios for open bugs aren't in the suite: each comes back with the PR that fixes its bug. The inventory above marks them "not covered yet". Bugs without a clean black-box assertion aren't tested: php sites render `.env`/`php.ini` from site-type-wp's templates; site-command's `ltrim($url, 'www.')` character-list bug; `var_dump()` in the inherit error; `--ssl` options not enforced on create (documented by the `--ssl=bogus` rollback scenario); no `/etc/hosts` entry for custom/inherit SSL sites. + +Other notes from the code: `--php=latest` maps to the php8.4 image, not the newest supported (8.5), maybe intended; `update --add/--delete-alias-domains` doesn't regenerate `main.conf` (stale `server_name`, harmless); a site created with `--dbhost=global-db:` and user-supplied credentials is stored with `db_host=global-db`, so `site delete` drops that pre-existing database and user. The old suite's label assertion was inverted and its mysql checks passed vacuously; both fixed in this context. + +## Tags + +- `@le` (1): issues one Let's Encrypt certificate for `bh-site-type-php-1.ee4-test.rt.gw` (plus www) and deletes the site in the same scenario. The name is fixed, so more than 5 runs in a week hit LE's duplicate-certificate limit. Needs `le-mail` in config.yml (checked first: stdin is /dev/null, so ee can't prompt). On CI it runs against Pebble (`.github/behat/pebble-setup.sh`): the issuer check reads `EE_BEHAT_LE_ISSUER` and the DNS check `EE_BEHAT_LE_DNS_SERVER` (defaults: Let's Encrypt, 1.1.1.1). +- `@le-real`: none; the `@le` scenario passes on Pebble. +- `@destructive` (3): `update --proxy-cache` restarts the global nginx-proxy container (self-restoring); the blank-alias and space-alias regressions (fixed by EasyEngine/site-type-php#114) would render an invalid nginx-proxy config for ~2 minutes if they regressed. +- `@slow` (12): the PHP version matrix (one site per version). +- `@security` (1): the shell-syntax site name payload (benign: `id>bhstpsec1` in the core root, removed after the scenario and by the cleanup). +- `@remote`: none (no backups in this package). + +## Environment rules followed + +- Sites: `bh-site-type-php-.test` (and `sub.`/`sub2.` children, `--x` variants); aliases `bh-site-type-php--.test`; global-db names `bh_stp_*` or the derived `bh_site_type_php_*`. `FeatureContext::cleanupTestState()` runs before the suite, after each feature and after the suite, and only touches these names: sites (children first), orphaned containers/networks/volumes/site dirs/proxy files, the suite's redis page-cache keys, `bh_stp_*`/`bh_site_type_php_*` databases and `bh_stp_*`/`bh-site-type-php-*` users that no remaining site row references, and `/etc/hosts` lines with the `# behat:site-type-php` marker or whose every name is a suite name. +- Data-loss guards: the suite refuses to start when a foreign site's volume prefix, derived db name, db name or db user could collide with the suite's names; the db setup steps only accept suite names; the collision scenarios only ever point a failed create's rollback at databases and users the scenario itself created. +- Commands run with stdin on `/dev/null`, so docker/compose never allocate a TTY (no hangs under nested ptys; `docker exec -it` fails fast instead). +- HTTP checks for aliases/extra names pin the host to 127.0.0.1 with curl's `--resolve` (keeps SNI for HTTPS); 127.0.0.1 arrives at nginx-proxy as 10.1.0.1 and bypasses HTTP auth. Only the `@le` scenario goes through public DNS to the public IP (it would get a 401 if global HTTP auth were enabled). +- Temporary files (custom certs) live in `/var/tmp/bh-site-type-php`, removed after the suite. + +## Site number registry + +1-9 create, 10-26 database (22.x/22-x collision), 2-7/27 create (volume collision), 30-38 cache, 40-55 php versions, 60-65 public dir, 70-79 alias domains, 80-89 ssl (85 + sub/sub2 children), 90-93 labels/redirect, 100-106 lifecycle, 197-199 never created. LE: `bh-site-type-php-1.ee4-test.rt.gw`. + +## Runtime estimate + +About 80 site creations (about 35 s each), plus deletes, updates and restarts: roughly 70-80 minutes for the whole suite, about 60 minutes with `--tags='~@slow'`, and about 55 minutes with `--tags='~@slow&&~@le'`. + +## Validation done + +- `vendor/bin/behat --dry-run --strict` in a private core copy on `upstream/develop`: no undefined or ambiguous steps. Behat 3.8 exits 1 on `--dry-run --strict` because every step is "skipped"; the upstream suite does the same. +- Copy the features with `cp -r`, not `cp -a`: Behat's Gherkin cache in `/tmp/behat_gherkin_cache` is keyed on mtime, so preserving older mtimes serves stale parses. +- `php -l` of `features/bootstrap/*.php` on php:7.4-cli and php:8.5-cli. diff --git a/features/alias-domains.feature b/features/alias-domains.feature new file mode 100644 index 0000000..92a0668 --- /dev/null +++ b/features/alias-domains.feature @@ -0,0 +1,138 @@ +Feature: Alias domains of php sites (create --alias-domains, update --add/--delete-alias-domains) + + Scenario: Create a php site with plain and wildcard alias domains + When I run "bin/ee site create bh-site-type-php-70.test --type=php --alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" + Then the command should succeed + And STDOUT should contain "| Alias Domains | bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test" + And the site "bh-site-type-php-70.test" should have the field "alias_domains" set to "bh-site-type-php-70.test,bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test" + + Scenario: Alias domains reach nginx-proxy and the site nginx + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + Then the file "/opt/easyengine/sites/bh-site-type-php-70.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test" + And the file "/opt/easyengine/sites/bh-site-type-php-70.test/docker-compose.yml" should contain "CERT_NAME=${VIRTUAL_HOST}" + And the file "/opt/easyengine/sites/bh-site-type-php-70.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-type-php-70.test bh-site-type-php-70-a.test bh-site-type-php-70-b.test *.bh-site-type-php-70.test;" + And the hosts file should have an entry for "bh-site-type-php-70.test" + And the hosts file should not have an entry for "bh-site-type-php-70-a.test" + + Scenario: Every alias domain serves the site + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + Then a GET request to "http://bh-site-type-php-70-a.test/" via "127.0.0.1" should return 200 + And the response should contain "Congratulations!" + And a GET request to "http://bh-site-type-php-70-b.test/" via "127.0.0.1" should return 200 + And a GET request to "http://any.bh-site-type-php-70.test/" via "127.0.0.1" should return 200 + And a GET request to "http://bh-site-type-php-70-z.test/" via "127.0.0.1" should not return 200 + + Scenario: An alias domain that already belongs to another site is refused + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + When I run "bin/ee site create bh-site-type-php-71.test --type=php --alias-domains=bh-site-type-php-70-a.test" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-70-a.test already exists as an alias domain for site: bh-site-type-php-70.test." + And the site "bh-site-type-php-71.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-71.test" should not exist + + Scenario: A site can not be created with a name that is another site's alias + Given a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + When I run "bin/ee site create bh-site-type-php-70-b.test --type=php" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-70-b.test already exists as an alias domain for site: bh-site-type-php-70.test. Please delete it from alias domains of bh-site-type-php-70.test if you want to create an independent site for it." + And the site "bh-site-type-php-70-b.test" should not exist + + Scenario: Spaces around alias domains are trimmed + When I run "bin/ee site create bh-site-type-php-78.test --type=php --alias-domains=' bh-site-type-php-78-a.test , bh-site-type-php-78-b.test '" + Then the command should succeed + And the site "bh-site-type-php-78.test" should have the field "alias_domains" set to "bh-site-type-php-78.test,bh-site-type-php-78-a.test,bh-site-type-php-78-b.test" + And a GET request to "http://bh-site-type-php-78-b.test/" via "127.0.0.1" should return 200 + + # Regression check for blank entries (fixed by EasyEngine/site-type-php#114). A regression renders an invalid nginx-proxy config (`upstream {`) that breaks proxy reloads for every site for ~2 minutes: hence @destructive. + @destructive + Scenario: Empty entries in --alias-domains are dropped + When I run "bin/ee site create bh-site-type-php-75.test --type=php --alias-domains='bh-site-type-php-75-a.test,,bh-site-type-php-75-b.test,'" + Then the command should succeed + And the site "bh-site-type-php-75.test" should have the field "alias_domains" set to "bh-site-type-php-75.test,bh-site-type-php-75-a.test,bh-site-type-php-75-b.test" + And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-75-a.test,bh-site-type-php-75-b.test" + And the file "/opt/easyengine/sites/bh-site-type-php-75.test/docker-compose.yml" should not contain ",," + + # Regression check: an alias like _wildcard. could overwrite proxy auth files (fixed by EasyEngine/site-type-php#114). + Scenario Outline: An alias domain that is not a valid hostname is refused + When I run "bin/ee site create --type=php --alias-domains=''" + Then the return code should be 1 + And STDERR should contain "Invalid alias domain(s)" + And the site "" should not exist + + Examples: + | site | alias | + | bh-site-type-php-76.test | _wildcard.bh-site-type-php-76.test | + | bh-site-type-php-76-1.test | -bh-site-type-php-76-a.test | + + # A space in VIRTUAL_HOST breaks the nginx-proxy config the same way as the blank entries above. + @destructive + Examples: + | site | alias | + | bh-site-type-php-76-2.test | bh-site-type-php-76 a.test | + + Scenario: Add an alias domain to a php site + Given a php site "bh-site-type-php-77.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-77-a.test" + Then the command should succeed + And STDOUT should contain "Success: Alias domains updated on site bh-site-type-php-77.test." + And the site "bh-site-type-php-77.test" should have the field "alias_domains" set to "bh-site-type-php-77.test,bh-site-type-php-77-a.test" + And the file "/opt/easyengine/sites/bh-site-type-php-77.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-php-77-a.test" + And I wait until a GET request to "http://bh-site-type-php-77-a.test/" via "127.0.0.1" returns 200 within 60 seconds + And a GET request to "http://bh-site-type-php-77.test/" should return 200 + + Scenario: Adding an alias domain the site already has is refused + Given a php site "bh-site-type-php-77.test" exists + And the site "bh-site-type-php-77.test" has the alias domain "bh-site-type-php-77-a.test" + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-77-a.test" + Then the return code should be 1 + And STDERR should contain "Error: Alias domains: bh-site-type-php-77-a.test is/are already present on the site." + + Scenario: Adding a mix of new and existing alias domains skips the existing ones + Given a php site "bh-site-type-php-77.test" exists + And the site "bh-site-type-php-77.test" has the alias domain "bh-site-type-php-77-a.test" + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-77-a.test,bh-site-type-php-77-b.test" + Then the command should succeed + And STDERR should contain "Following domains: bh-site-type-php-77-a.test is/are already present on site, skipping addition of those." + And the site "bh-site-type-php-77.test" should have the field "alias_domains" set to "bh-site-type-php-77.test,bh-site-type-php-77-a.test,bh-site-type-php-77-b.test" + + Scenario: Delete an alias domain from a php site + Given a php site "bh-site-type-php-77.test" exists + And the site "bh-site-type-php-77.test" has the alias domain "bh-site-type-php-77-c.test" + When I run "bin/ee site update bh-site-type-php-77.test --delete-alias-domains=bh-site-type-php-77-c.test" + Then the command should succeed + And STDOUT should contain "Success: Alias domains updated on site bh-site-type-php-77.test." + And the site "bh-site-type-php-77.test" should have the field "alias_domains" not containing "bh-site-type-php-77-c.test" + And the file "/opt/easyengine/sites/bh-site-type-php-77.test/docker-compose.yml" should not contain "bh-site-type-php-77-c.test" + And I wait until a GET request to "http://bh-site-type-php-77-c.test/" via "127.0.0.1" no longer returns 200 within 60 seconds + + Scenario: The primary domain can not be deleted from the alias domains + Given a php site "bh-site-type-php-77.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --delete-alias-domains=bh-site-type-php-77.test" + Then the return code should be 1 + And STDERR should contain "Primary site domain: `bh-site-type-php-77.test` can not be deleted." + + Scenario: Deleting an alias domain the site does not have is refused + Given a php site "bh-site-type-php-77.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --delete-alias-domains=bh-site-type-php-77-nope.test" + Then the return code should be 1 + And STDERR should contain "Domains to delete is/are not a subset of already existing alias domains." + + Scenario: An existing site can not be added as an alias domain + Given a php site "bh-site-type-php-77.test" exists + And a php site "bh-site-type-php-74.test" exists + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-74.test" + Then the return code should be 1 + And STDERR should contain "bh-site-type-php-74.test already exists as a site." + + Scenario: Another site's alias domain can not be added + Given a php site "bh-site-type-php-77.test" exists + And a php site "bh-site-type-php-70.test" created with "--alias-domains='bh-site-type-php-70-a.test,bh-site-type-php-70-b.test,*.bh-site-type-php-70.test'" exists + When I run "bin/ee site update bh-site-type-php-77.test --add-alias-domains=bh-site-type-php-70-a.test" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-70-a.test already exists as an alias domain for site: bh-site-type-php-70.test." + + Scenario: A wildcard alias domain can be added later + Given a php site "bh-site-type-php-79.test" exists + When I run "bin/ee site update bh-site-type-php-79.test --add-alias-domains='*.bh-site-type-php-79.test'" + Then the command should succeed + And I wait until a GET request to "http://sub.bh-site-type-php-79.test/" via "127.0.0.1" returns 200 within 60 seconds diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index fb9448d..1eec129 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -12,9 +12,20 @@ include_once( EE_ROOT . '/php/EE/Runner.php' ); include_once( EE_ROOT . '/php/utils.php' ); -define( 'EE', true ); -define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); -define( 'EE_ROOT_DIR', '/opt/easyengine' ); +defined( 'EE' ) || define( 'EE', true ); +defined( 'EE_VERSION' ) || define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); + +// php/init-ee.php may already have defined it (through the includes above). +if ( ! defined( 'EE_ROOT_DIR' ) ) { + $root_alias = '/opt'; + if ( 'Darwin' === php_uname( 's' ) ) { + $sys_name = posix_getpwuid(posix_geteuid())['name']; + if ( ! empty( $sys_name ) ) { + $root_alias = '/Users/' . $sys_name; + } + } + define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +} require_once EE_ROOT . '/php/bootstrap.php'; @@ -47,6 +58,8 @@ use Behat\Behat\Context\Context; use Behat\Behat\Hook\Scope\AfterFeatureScope; use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Testwork\Hook\Scope\BeforeSuiteScope; +use Behat\Testwork\Hook\Scope\AfterSuiteScope; use Behat\Gherkin\Node\PyStringNode, Behat\Gherkin\Node\TableNode; @@ -55,10 +68,35 @@ class FeatureContext implements Context { + /** + * Sites owned by this suite: `bh-site-type-php-.` and their subdomains. Cleanup only ever touches these. + */ + const SITE_PATTERN = '/(^|\.)bh-site-type-php-/'; + + /** + * Global-db databases and users created by this suite (explicit `bh_stp_*` names, or derived from the site name). + */ + const DB_NAME_LIKE = [ 'bh\\_stp\\_%', 'bh\\_site\\_type\\_php\\_%' ]; + const DB_USER_LIKE = [ 'bh\\_stp\\_%', 'bh-site-type-php-%' ]; + + const TMP_DIR = '/var/tmp/bh-site-type-php'; + const HOSTS_MARKER = '# behat:site-type-php'; + + const GLOBAL_DB_CONTAINER = 'services_global-db_1'; + const GLOBAL_REDIS_CONTAINER = 'services_global-redis_1'; + const PROXY_CONTAINER = 'services_global-nginx-proxy_1'; + public $command; + public $commands; public $webroot_path; public $ee_path; + /** @var array|null Last HTTP response: code, headers, body, error. */ + private $response; + + /** @var array Container start times noted by `I note the start time of ...`. */ + private $start_times = []; + /** * Initializes context. */ @@ -74,39 +112,42 @@ public function __construct() public function eePharIsPresent() { // Checks if phar already exists, replaces it - if( file_exists( 'ee-old.phar' ) ) { + if(file_exists('ee-old.phar')) { // Below exec call is required as currenly `ee cli update` is ran with root // which updates ee.phar with root privileges. - exec( "sudo rm ee.phar" ); - copy( 'ee-old.phar','ee.phar' ); + exec("sudo rm ee.phar"); + copy('ee-old.phar','ee.phar'); return 0; } - exec( "php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status ); - if ( 0 !== $return_status ) { - throw new Exception( "Unable to generate phar" . $return_status ); + exec("php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status); + if (0 !== $return_status) { + throw new Exception("Unable to generate phar" . $return_status); } // Cache generaed phar as it is expensive to generate one - copy( 'ee.phar','ee-old.phar' ); + copy('ee.phar','ee-old.phar'); } /** * @Given :command is installed */ - public function isInstalled( $command ) + public function isInstalled($command) { - exec( "type " . $command, $output, $return_status ); - if ( 0 !== $return_status ) { - throw new Exception( $command . " is not installed! Exit code is:" . $return_status ); + exec("type " . $command, $output, $return_status); + if (0 !== $return_status) { + throw new Exception($command . " is not installed! Exit code is:" . $return_status); } } /** - * @When /I run '(.*)'|"(.*)"/ + * Two anchored patterns: the old unanchored `/I run '(.*)'|"(.*)"/` matched every step containing a double-quoted string. + * + * @When /^I run '(.*)'$/ + * @When /^I run "(.*)"$/ */ - public function iRun( $command ) + public function iRun($command) { - $this->commands[] = EE::launch($command, false, true); + $this->commands[] = self::run( $command ); } /** @@ -114,7 +155,26 @@ public function iRun( $command ) */ public function iTry( $command ) { - $this->commands[] = EE::launch($command, false, true); + $this->commands[] = self::run( $command ); + } + + /** + * @Then return value should be 0 + */ + public function returnValueShouldBe0() + { + $this->assertReturnCode( 0 ); + } + + /** + * @Then return value of command :index should be 0 + */ + public function returnValueOfCommandShouldBe0($index) + { + $result = $this->commandAt( $index ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Return code of command $index is {$result->return_code}, not 0.\n" . $this->describe( $result ) ); + } } /** @@ -126,11 +186,11 @@ public function afterDelayOfSeconds( $time ) } /** - * @Then /(STDOUT|STDERR) should return exactly/ + * @Then /^(STDOUT|STDERR) should return exactly$/ */ - public function stdoutShouldReturnExactly( $output_stream, PyStringNode $expected_output ) + public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) { - $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + $command_output = $this->streamOf( $this->lastCommand(), $output_stream ); $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); @@ -140,56 +200,74 @@ public function stdoutShouldReturnExactly( $output_stream, PyStringNode $expecte } /** - * @Then /(STDOUT|STDERR) should return something like/ + * @Then /^(STDOUT|STDERR) of command (\d+) should return exactly$/ */ - public function stdoutShouldReturnSomethingLike( $output_stream, PyStringNode $expected_output ) + public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStringNode $expected_output) { - $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + $command_output = $this->streamOf( $this->commandAt( $index ), $output_stream ); + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); - $expected_out = isset( $expected_output->getStrings()[0] ) ? $expected_output->getStrings()[0] : ''; - if ( strpos( $command_output, $expected_out ) === false ) { - throw new Exception( "Actual output is:\n" . $command_output ); + if ( $expected_output->getRaw() !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); } } /** - * @Then The :site db entry should be removed + * @Then /^(STDOUT|STDERR) should return something like$/ + */ + public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) + { + $command_output = $this->streamOf( $this->lastCommand(), $output_stream ); + + if (strpos($command_output, $expected_output->getRaw()) === false) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /^(STDOUT|STDERR) of command (\d+) should return something like$/ */ - public function theDbEntryShouldBeRemoved( $site ) + public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, PyStringNode $expected_output) { - $out = shell_exec( "sudo bin/ee site list" ); - if ( false !== strpos( $out, $site ) ) { - throw new Exception( "$site db entry not been removed!" ); + $command_output = $this->streamOf( $this->commandAt( $index ), $output_stream ); + + if (strpos($command_output, $expected_output->getRaw()) === false) { + throw new Exception("Actual output is:\n" . $command_output); } + } + /** + * @Then The :site db entry should be removed + */ + public function theDbEntryShouldBeRemoved($site) + { + if ( null !== $this->siteRow( $site ) ) { + throw new Exception("$site db entry not been removed!"); + } } /** * @Then The :site webroot should be removed */ - public function theWebrootShouldBeRemoved( $site ) + public function theWebrootShouldBeRemoved($site) { - if ( file_exists( EE_SITE_ROOT . '/' . $site ) ) { - throw new Exception( "Webroot has not been removed!" ); + if ( file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been removed!"); } } /** + * Looks containers up by label: compose v2 names them `--1`, not `__1`. + * * @Then Following containers of site :site should be removed: */ - public function followingContainersOfSiteShouldBeRemoved( $site, TableNode $table ) + public function followingContainersOfSiteShouldBeRemoved($site, TableNode $table) { - $containers = $table->getHash(); - $site_name = implode( explode( '.', $site ) ); - - foreach ( $containers as $container ) { - - $sevice = $container['container']; - $container_name = $site_name . '_' . $sevice . '_1'; - - exec( "docker inspect -f '{{.State.Running}}' $container_name > /dev/null 2>&1", $exec_out, $return ); - if ( ! $return ) { - throw new Exception( "$container_name has not been removed!" ); + foreach ( $table->getHash() as $container ) { + $id = $this->serviceContainerId( $site, $container['container'], false ); + if ( '' !== $id ) { + throw new Exception( "The {$container['container']} container of $site ($id) has not been removed!" ); } } } @@ -197,10 +275,10 @@ public function followingContainersOfSiteShouldBeRemoved( $site, TableNode $tabl /** * @Then The site :site should have webroot */ - public function theSiteShouldHaveWebroot( $site ) + public function theSiteShouldHaveWebroot($site) { if ( ! file_exists( EE_SITE_ROOT . '/' . $site ) ) { - throw new Exception( "Webroot has not been created!" ); + throw new Exception("Webroot has not been created!"); } } @@ -217,103 +295,71 @@ public function theSiteShouldHaveIndexFile( $site ) /** * @Then Request on :site should contain following headers: */ - public function requestOnShouldContainFollowingHeaders( $site, TableNode $table ) + public function requestOnShouldContainFollowingHeaders($site, TableNode $table) { $url = 'http://' . $site; - $ch = curl_init(); - - curl_setopt( $ch, CURLOPT_URL, $url ); - curl_setopt( $ch, CURLOPT_HEADER, true ); - curl_setopt( $ch, CURLOPT_NOBODY, true ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_VERBOSE, true ); - - $headers = curl_exec( $ch ); + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + $headers = curl_exec($ch); curl_close($ch); $rows = $table->getHash(); - foreach ( $rows as $row ) { - if ( strpos( $headers, $row['header'] ) === false ) { - throw new Exception( "Unable to find " . $row['header'] . "\nActual output is : " . $headers ); + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); } } } /** - * @AfterScenario - */ - public function cleanupScenario( AfterScenarioScope $scope ) - { - $this->commands = []; - chdir( $this->ee_path ); - } - - /** - * @AfterFeature + * @Then Request on :host with header :header should contain following headers: */ - public static function cleanup( AfterFeatureScope $scope ) + public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) { - $test_sites = [ - 'php.test', - 'example.test', - 'www.example1.test', - 'labels.test', - 'php-local-db.test', - 'php-local-redis.test', - 'php-local-db-redis.test', - ]; - - $result = EE::launch( 'sudo bin/ee site list --format=text', false, true ); - $running_sites = explode( "\n", $result->stdout ); - $sites_to_delete = array_intersect( $test_sites, $running_sites ); - - foreach ( $sites_to_delete as $site ) { - exec( "sudo bin/ee site delete $site --yes" ); - } - - if( file_exists( 'ee.phar' ) ) { - unlink( 'ee.phar' ); - } - if( file_exists( 'ee-old.phar' ) ) { - unlink( 'ee-old.phar' ); - } - } + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $host); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + $headers = curl_exec($ch); - /** - * @Then There should be :expected_running_containers containers with labels - */ - public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) - { - $labels = $pyStringNode->getStrings(); - $label_string = implode(' -f label=',$labels); + curl_close($ch); - $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); - $running_containers = (int) trim($result->stdout); + $rows = $table->getHash(); - if($expected_running_containers === $running_containers) { - throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } } } /** - * @Then Request on :host with header :header should contain following headers: + * @Then Request on :host with resolve option :resolve should contain following headers: */ - public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) + public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $resolve, TableNode $table) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $host); curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_RESOLVE, [ $resolve ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_VERBOSE, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); $headers = curl_exec($ch); - curl_close($ch); $rows = $table->getHash(); @@ -326,16 +372,39 @@ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, } /** + * The old version threw when the count matched, so it passed for any other count. + * + * @Then There should be :expected_running_containers containers with labels + */ + public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + { + $filters = ''; + foreach ( $pyStringNode->getStrings() as $label ) { + $filters .= ' -f ' . escapeshellarg( 'label=' . trim( $label ) ); + } + + $result = self::run( "docker ps -q $filters" ); + $running_containers = count( array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + + if ( (int) $expected_running_containers !== $running_containers ) { + throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + } + } + + /** + * No `-it`: without a TTY `docker exec -it` fails, and under the harness's nested ptys it can hang. + * * @Then Check global redis cache for :site */ public function checkGlobalRedisCacheOfSite($site) { - exec( sprintf( "docker exec -it %s redis-cli set 'easyengine' 'rocks'", GLOBAL_REDIS_CONTAINER ) ); - $output = exec( sprintf( "docker exec -it %s redis-cli get 'easyengine'", GLOBAL_REDIS_CONTAINER ) ); - if ( '"rocks"' !== $output ) { - throw new Exception("Global redis not working for $site site. Getting '$output' instead of 'rock'"); + $key = 'behat-' . $site; + $this->globalRedis( sprintf( 'set %s rocks', escapeshellarg( $key ) ) ); + $output = trim( $this->globalRedis( sprintf( 'get %s', escapeshellarg( $key ) ) )->stdout ); + $this->globalRedis( sprintf( 'del %s', escapeshellarg( $key ) ) ); + if ( 'rocks' !== $output ) { + throw new Exception("Global redis not working for $site site. Getting '$output' instead of 'rocks'"); } - exec( sprintf( "docker exec -it %s redis-cli del 'easyengine'", GLOBAL_REDIS_CONTAINER ) ); } /** @@ -343,16 +412,17 @@ public function checkGlobalRedisCacheOfSite($site) */ public function checkLocalRedisCacheOfSite($site) { - $site_root_folder = EE_SITE_ROOT . '/' . $site; - exec("cd $site_root_folder && docker-compose exec redis redis-cli set 'easyengine' 'rocks'"); - $output = exec("cd $site_root_folder && docker-compose exec redis redis-cli get 'easyengine'"); + $this->execInService( $site, 'redis', "redis-cli set easyengine rocks" ); + $output = trim( $this->execInService( $site, 'redis', "redis-cli get easyengine" )->stdout ); + $this->execInService( $site, 'redis', "redis-cli del easyengine" ); if ( 'rocks' !== $output ) { throw new Exception("Local redis not working for $site site. Getting '$output' instead of 'rocks'."); } - exec("cd $site_root_folder && docker-compose exec redis redis-cli del 'easyengine'"); } /** + * Uses the mysql CLI of the php image; on php 8.2+ it fails against global-db, whose MariaDB doesn't offer the TLS that client demands. + * * @Then Check local mysql database connection for :site */ public function checkLocalDbConnection(string $site) @@ -369,7 +439,9 @@ public function checkGlobalDbConnection(string $site) } /** - * Helper function to check global and local database connection. + * Checks the site's DB credentials with the mysql CLI inside its php container. + * + * The old version passed whenever `ee shell` printed nothing to stdout, even on failure. * * @param string $site * @param string $db_type @@ -378,33 +450,1600 @@ public function checkGlobalDbConnection(string $site) */ public function checkMysqlConnection(string $site, string $db_type) { - $db = new SQLite3( EE_ROOT_DIR . '/db/ee.sqlite' ); - $query = "SELECT `db_user`, `db_password` FROM sites WHERE site_url='".$site."' LIMIT 1;"; - $exec_query = $db->query( $query ); - $site_info = $exec_query->fetchArray( SQLITE3_ASSOC ); + $site_info = $this->siteRow( $site ); if ( empty( $site_info['db_user'] ) || empty( $site_info['db_password'] ) ) { - throw new exception( 'Database credentials not found.' ); + throw new Exception( 'Database credentials not found.' ); } - $mysql_query = sprintf( - 'mysql -h"%s" -u"%s" -p"%s" -e"exit"', - $db_type, - $site_info['db_user'], - $site_info['db_password'] - ); - - $cmd_command = sprintf( - 'ee shell %s --command="%s"', + $result = $this->execInService( $site, - $mysql_query + 'php', + sprintf( 'mysql -h%s -u%s -p%s -e exit', escapeshellarg( $db_type ), escapeshellarg( $site_info['db_user'] ), escapeshellarg( $site_info['db_password'] ) ) ); - $db_connection = exec( $cmd_command ); + if ( 0 !== $result->return_code ) { + throw new Exception( "mysql CLI in the php container of $site could not connect to $db_type.\n" . $this->describe( $result ) ); + } + } + + /** + * @AfterScenario + */ + public function cleanupScenario(AfterScenarioScope $scope) + { + $this->commands = []; + $this->response = null; + $this->start_times = []; + chdir($this->ee_path); + + // Marker file the shell-syntax site name payload may create in the working directory. + if ( file_exists( EE_ROOT . '/bhstpsec1' ) ) { + unlink( EE_ROOT . '/bhstpsec1' ); + } + } + + /** + * @BeforeSuite + */ + public static function prepareSuite( BeforeSuiteScope $scope ) + { + // Volume prefixes drop "." and "-" and default db names map them to "_", and the create rollback drops + // colliding databases and users. Refuse to run next to a foreign site whose volumes, db or db user could collide. + foreach ( self::sqliteRows( 'SELECT site_url, db_name, db_user FROM sites' ) as $row ) { + if ( 1 === preg_match( self::SITE_PATTERN, $row['site_url'] ) ) { + continue; + } + $collides = 0 === strpos( str_replace( [ '.', '-' ], '', $row['site_url'] ), 'bhsitetypephp' ) + || 0 === strpos( str_replace( [ '.', '-' ], '_', $row['site_url'] ), 'bh_site_type_php_' ) + || 1 === preg_match( '/^(bh_stp_|bh_site_type_php_)/', (string) $row['db_name'] ) + || 1 === preg_match( '/^(bh_stp_|bh-site-type-php-)/', (string) $row['db_user'] ); + if ( $collides ) { + throw new Exception( "Refusing to run: the volumes or database of site {$row['site_url']} could collide with this suite's names." ); + } + } + self::cleanupTestState(); + if ( ! is_dir( self::TMP_DIR ) ) { + mkdir( self::TMP_DIR, 0700, true ); + } + } + + /** + * On CI, EE's hardcoded Let's Encrypt host must point at Pebble or a blackhole (.github/behat/pebble-setup.sh), never the real service. + * + * @BeforeSuite + */ + public static function refuseRealAcmeOnCi() + { + if ( getenv( 'CI' ) && ! self::acme_host_is_local() ) { + throw new Exception( 'acme-v02.api.letsencrypt.org resolves to ' . gethostbyname( 'acme-v02.api.letsencrypt.org' ) . ' on CI; run `.github/behat/pebble-setup.sh setup` (le lanes) or `block` (every other lane) first.' ); + } + } + + /** + * @le issues certificates: only against Pebble, or on a real test host with EE_BEHAT_LE_REAL=1 (ignored on CI). + * + * @BeforeScenario @le + */ + public function requireTestAcme() + { + if ( getenv( 'EE_BEHAT_LE_ISSUER' ) && self::acme_host_is_local() ) { + return; + } + if ( '1' === getenv( 'EE_BEHAT_LE_REAL' ) && ! getenv( 'CI' ) ) { + return; + } + throw new Exception( '@le needs Pebble (.github/behat/pebble-setup.sh setup), or EE_BEHAT_LE_REAL=1 on a host with real DNS for *.ee4-test.rt.gw.' ); + } + + /** + * True when the LE host resolves to a private, loopback or TEST-NET address (Pebble's front proxy or the blackhole). + */ + private static function acme_host_is_local() + { + $ip = gethostbyname( 'acme-v02.api.letsencrypt.org' ); + + return (bool) preg_match( '/^(10\.|127\.|192\.168\.|192\.0\.2\.|172\.(1[6-9]|2\d|3[01])\.)/', $ip ); + } + + /** + * Deletes only this suite's sites; never "all sites" like the old context did. + * + * @AfterFeature + */ + public static function cleanup(AfterFeatureScope $scope) + { + self::cleanupTestState(); + + if(file_exists('ee.phar')) { + unlink('ee.phar'); + } + if(file_exists('ee-old.phar')) { + unlink('ee-old.phar'); + } + } + + /** + * @AfterSuite + */ + public static function finishSuite( AfterSuiteScope $scope ) + { + self::cleanupTestState(); + if ( is_dir( self::TMP_DIR ) ) { + self::run( 'rm -rf ' . escapeshellarg( self::TMP_DIR ) ); + } + } + + /* + * ===================================================================================== + * Shared steps (added for site-type-php coverage). Generic on purpose so other packages + * can reuse them. All assertions act on the LAST command / response of the scenario. + * ===================================================================================== + */ + + /** + * Runs a command with `bin/ee` resolved to the absolute path, from another working directory. + * + * @When I change to the directory :dir and run :command + */ + public function iChangeToTheDirectoryAndRun( $dir, $command ) + { + $ee = realpath( EE_ROOT ) . '/bin/ee'; + $command = preg_replace( '#^bin/ee\b#', escapeshellarg( $ee ), $command ); + $this->commands[] = self::run( $command, $dir ); + } + + /** + * @When I execute :command in the :service service of site :site + */ + public function iExecuteInTheServiceOfSite( $command, $service, $site ) + { + $this->commands[] = $this->execInService( $site, $service, $command ); + } + + /** + * @When I wait until the command :command succeeds within :seconds seconds + */ + public function iWaitUntilTheCommandSucceeds( $command, $seconds ) + { + $deadline = time() + (int) $seconds; + do { + $result = self::run( $command ); + if ( 0 === $result->return_code ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "`$command` did not succeed within $seconds seconds.\n" . $this->describe( $result ) ); + } + + /** + * @Then the command should succeed + */ + public function theCommandShouldSucceed() + { + $this->assertReturnCode( 0 ); + } + + /** + * @Then the command should fail + */ + public function theCommandShouldFail() + { + $result = $this->lastCommand(); + if ( 0 === $result->return_code ) { + throw new Exception( "Expected a non-zero return code.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then the return code should be :code + */ + public function theReturnCodeShouldBe( $code ) + { + $this->assertReturnCode( (int) $code ); + } + + /** + * @Then /^(STDOUT|STDERR) should( not|) contain:$/ + */ + public function streamShouldContainBlock( $stream, $not, PyStringNode $text ) + { + $this->assertStreamContains( $stream, $text->getRaw(), '' !== $not ); + } + + /** + * @Then /^(STDOUT|STDERR) should( not|) contain "([^"]*)"$/ + */ + public function streamShouldContainText( $stream, $not, $text ) + { + $this->assertStreamContains( $stream, $text, '' !== $not ); + } - if ( ! empty( $db_connection ) ) { - throw new Exception( $db_connection ); + /** + * @Then /^(STDOUT|STDERR) should match:$/ + */ + public function streamShouldMatch( $stream, PyStringNode $regex ) + { + $output = $this->streamOf( $this->lastCommand(), $stream ); + if ( 1 !== preg_match( trim( $regex->getRaw() ), $output ) ) { + throw new Exception( "$stream does not match {$regex->getRaw()}.\n" . $this->describe( $this->lastCommand() ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be empty$/ + */ + public function streamShouldBeEmpty( $stream ) + { + if ( '' !== trim( $this->streamOf( $this->lastCommand(), $stream ) ) ) { + throw new Exception( "$stream is not empty.\n" . $this->describe( $this->lastCommand() ) ); + } + } + + /** + * @Then STDOUT should be valid json + */ + public function stdoutShouldBeValidJson() + { + $this->decodedStdout(); + } + + /** + * Dot paths are supported (`a.b`). Scalars are compared as strings; null and false compare as "". + * + * @Then the JSON in STDOUT should have :key set to :value + */ + public function theJsonInStdoutShouldHaveKeySetTo( $key, $value ) + { + $data = $this->decodedStdout(); + $actual = $this->jsonPath( $data, $key ); + if ( $this->scalarToString( $actual ) !== $value ) { + throw new Exception( sprintf( "JSON key %s is %s, expected %s.\nSTDOUT: %s", $key, var_export( $actual, true ), $value, $this->lastCommand()->stdout ) ); + } + } + + /** + * @Then the JSON list in STDOUT should contain an item with :key set to :value + */ + public function theJsonListShouldContainItem( $key, $value ) + { + foreach ( (array) $this->decodedStdout() as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && $this->scalarToString( $item[ $key ] ) === $value ) { + return; + } } + throw new Exception( "No item with $key=$value in:\n" . $this->lastCommand()->stdout ); } + /** + * @Then the JSON list in STDOUT should contain an item with :key set to :value and :key2 set to :value2 + */ + public function theJsonListShouldContainItemWithBoth( $key, $value, $key2, $value2 ) + { + foreach ( (array) $this->decodedStdout() as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && array_key_exists( $key2, $item ) + && $this->scalarToString( $item[ $key ] ) === $value && $this->scalarToString( $item[ $key2 ] ) === $value2 ) { + return; + } + } + throw new Exception( "No item with $key=$value and $key2=$value2 in:\n" . $this->lastCommand()->stdout ); + } + + /** + * @Then the JSON list in STDOUT should not contain an item with :key set to :value + */ + public function theJsonListShouldNotContainItem( $key, $value ) + { + foreach ( (array) $this->decodedStdout() as $item ) { + if ( is_array( $item ) && array_key_exists( $key, $item ) && $this->scalarToString( $item[ $key ] ) === $value ) { + throw new Exception( "Unexpected item with $key=$value in:\n" . $this->lastCommand()->stdout ); + } + } + } + + /** + * @Then the site :site should exist + */ + public function theSiteShouldExist( $site ) + { + if ( null === $this->siteRow( $site ) ) { + throw new Exception( "Site $site has no row in the sites table." ); + } + } + + /** + * @Then the site :site should not exist + */ + public function theSiteShouldNotExist( $site ) + { + if ( null !== $this->siteRow( $site ) ) { + throw new Exception( "Site $site still has a row in the sites table." ); + } + } + + /** + * @Then /^the site "([^"]*)" should be (enabled|disabled)$/ + */ + public function theSiteShouldBeEnabledOrDisabled( $site, $state ) + { + $row = $this->requireSiteRow( $site ); + if ( (int) $row['site_enabled'] !== ( 'enabled' === $state ? 1 : 0 ) ) { + throw new Exception( "Site $site has site_enabled={$row['site_enabled']}, expected it to be $state." ); + } + } + + /** + * @Then /^the site "([^"]*)" should( not|) have the field "([^"]*)" set to "([^"]*)"$/ + */ + public function theSiteShouldHaveFieldSetTo( $site, $not, $field, $value ) + { + $row = $this->requireSiteRow( $site, $field ); + $equal = $this->scalarToString( $row[ $field ] ) === $value; + if ( $equal === ( '' !== $not ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, expected %s%s.', $site, $field, var_export( $row[ $field ], true ), $not ? 'anything but ' : '', $value ) ); + } + } + + /** + * @Then /^the site "([^"]*)" should have the field "([^"]*)" (containing|not containing) "([^"]*)"$/ + */ + public function theSiteShouldHaveFieldContaining( $site, $field, $mode, $text ) + { + $row = $this->requireSiteRow( $site, $field ); + $found = false !== strpos( $this->scalarToString( $row[ $field ] ), $text ); + if ( $found !== ( 'containing' === $mode ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, expected it %s %s.', $site, $field, var_export( $row[ $field ], true ), $mode, $text ) ); + } + } + + /** + * @Then the site :site should have the field :field matching :regex + */ + public function theSiteShouldHaveFieldMatching( $site, $field, $regex ) + { + $row = $this->requireSiteRow( $site, $field ); + if ( 1 !== preg_match( $regex, $this->scalarToString( $row[ $field ] ) ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, which does not match %s.', $site, $field, var_export( $row[ $field ], true ), $regex ) ); + } + } + + /** + * "Empty" is PHP-falsy, the way ee reads these columns: a boolean false is saved as "0" (e.g. site_ssl after --ssl=off). + * + * @Then /^the site "([^"]*)" should have an? (empty|non-empty) "([^"]*)"$/ + */ + public function theSiteShouldHaveEmptyField( $site, $state, $field ) + { + $row = $this->requireSiteRow( $site, $field ); + $empty = in_array( $this->scalarToString( $row[ $field ] ), [ '', '0' ], true ); + if ( $empty !== ( 'empty' === $state ) ) { + throw new Exception( sprintf( 'Site %s has %s=%s, expected it to be %s.', $site, $field, var_export( $row[ $field ], true ), $state ) ); + } + } + + /** + * @Then /^the (file|directory) "([^"]*)" should( not|) exist$/ + */ + public function thePathShouldExist( $type, $path, $not ) + { + clearstatcache(); + $exists = 'file' === $type ? is_file( $path ) : is_dir( $path ); + if ( $exists === ( '' !== $not ) ) { + throw new Exception( sprintf( 'The %s %s %s.', $type, $path, $exists ? 'exists' : 'does not exist' ) ); + } + } + + /** + * @Then the path :path should be a symlink + */ + public function thePathShouldBeASymlink( $path ) + { + clearstatcache(); + if ( ! is_link( $path ) ) { + throw new Exception( "$path is not a symlink." ); + } + } + + /** + * @Then /^the file "([^"]*)" should( not|) contain:$/ + */ + public function theFileShouldContain( $path, $not, PyStringNode $text ) + { + $this->assertFileContains( $path, $text->getRaw(), '' !== $not ); + } + + /** + * @Then /^the file "([^"]*)" should( not|) contain "([^"]*)"$/ + */ + public function theFileShouldContainText( $path, $not, $text ) + { + $this->assertFileContains( $path, $text, '' !== $not ); + } + + /** + * @Then the file :path should match :regex + */ + public function theFileShouldMatch( $path, $regex ) + { + $content = $this->readFile( $path ); + if ( 1 !== preg_match( $regex, $content ) ) { + throw new Exception( "$path does not match $regex. Content:\n$content" ); + } + } + + /** + * @Then the files :first and :second should be identical + */ + public function theFilesShouldBeIdentical( $first, $second ) + { + if ( $this->readFile( $first ) !== $this->readFile( $second ) ) { + throw new Exception( "$first and $second differ." ); + } + } + + /** + * @Given I append :text to the file :path + */ + public function iAppendToTheFile( $text, $path ) + { + if ( false === file_put_contents( $path, PHP_EOL . $text . PHP_EOL, FILE_APPEND ) ) { + throw new Exception( "Could not append to $path." ); + } + } + + /** + * @Given I write :text to the file :path + */ + public function iWriteToTheFile( $text, $path ) + { + if ( ! is_dir( dirname( $path ) ) ) { + mkdir( dirname( $path ), 0755, true ); + } + if ( false === file_put_contents( $path, $text ) ) { + throw new Exception( "Could not write $path." ); + } + } + + /** + * @Then the docker container :name should be running + */ + public function theDockerContainerShouldBeRunning( $name ) + { + $result = self::run( 'docker inspect -f {{.State.Running}} ' . escapeshellarg( $name ) ); + if ( 'true' !== trim( $result->stdout ) ) { + throw new Exception( "Container $name is not running.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not|) be running$/ + */ + public function theServiceOfSiteShouldBeRunning( $service, $site, $not ) + { + $running = '' !== $this->serviceContainerId( $site, $service ); + if ( $running === ( '' !== $not ) ) { + throw new Exception( sprintf( "The %s service of %s is %s.\n%s", $service, $site, $running ? 'running' : 'not running', $this->siteContainersSummary( $site ) ) ); + } + } + + /** + * @Then the :service service of site :site should not exist + */ + public function theServiceOfSiteShouldNotExist( $service, $site ) + { + $id = $this->serviceContainerId( $site, $service, false ); + if ( '' !== $id ) { + throw new Exception( "The $service container of $site still exists ($id)." ); + } + } + + /** + * @Then the site :site should have :count running containers + */ + public function theSiteShouldHaveRunningContainers( $site, $count ) + { + $ids = $this->lines( self::run( 'docker ps -q -f ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) )->stdout ); + if ( count( $ids ) !== (int) $count ) { + throw new Exception( sprintf( "Expected %d running containers for %s, found %d.\n%s", $count, $site, count( $ids ), $this->siteContainersSummary( $site ) ) ); + } + } + + /** + * @Then the site :site should have :count docker volumes + */ + public function theSiteShouldHaveDockerVolumes( $site, $count ) + { + $volumes = $this->siteVolumes( $site ); + if ( count( $volumes ) !== (int) $count ) { + throw new Exception( sprintf( "Expected %d volumes labelled for %s, found %d:\n%s", $count, $site, count( $volumes ), implode( "\n", $volumes ) ) ); + } + } + + /** + * @Then /^the docker network "([^"]*)" should( not|) exist$/ + */ + public function theDockerNetworkShouldExist( $network, $not ) + { + $exists = 0 === self::run( 'docker network inspect ' . escapeshellarg( $network ) )->return_code; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Docker network $network " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Then the docker network :network should have the label :label + */ + public function theDockerNetworkShouldHaveTheLabel( $network, $label ) + { + list( $key, $value ) = array_pad( explode( '=', $label, 2 ), 2, '' ); + $result = self::run( 'docker network inspect -f ' . escapeshellarg( '{{index .Labels "' . $key . '"}}' ) . ' ' . escapeshellarg( $network ) ); + if ( 0 !== $result->return_code || trim( $result->stdout ) !== $value ) { + throw new Exception( "Docker network $network does not have the label $label.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not|) be attached to the docker network "([^"]*)"$/ + */ + public function theServiceShouldBeAttachedToNetwork( $service, $site, $not, $network ) + { + $id = $this->requireServiceContainer( $site, $service ); + $networks = $this->lines( self::run( 'docker inspect -f ' . escapeshellarg( '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' ) . ' ' . $id )->stdout ); + $attached = in_array( $network, $networks, true ); + if ( $attached === ( '' !== $not ) ) { + throw new Exception( sprintf( "The %s service of %s is %s to %s. Networks: %s", $service, $site, $attached ? 'attached' : 'not attached', $network, implode( ', ', $networks ) ) ); + } + } + + /** + * @Then the :service service of site :site should use the image :image + */ + public function theServiceShouldUseTheImage( $service, $site, $image ) + { + $id = $this->requireServiceContainer( $site, $service ); + $actual = trim( self::run( 'docker inspect -f {{.Config.Image}} ' . $id )->stdout ); + if ( $actual !== $image ) { + throw new Exception( "The $service service of $site runs $actual, expected $image." ); + } + } + + /** + * Expects `:` with the tag pinned in core's img-versions.json. + * + * @Then the :service service of site :site should use the pinned :image image + */ + public function theServiceShouldUseThePinnedImage( $service, $site, $image ) + { + $this->theServiceShouldUseTheImage( $service, $site, $image . ':' . $this->pinnedTag( $image ) ); + } + + /** + * @Then the docker-compose file of site :site should use the pinned :image image + */ + public function theComposeFileShouldUseThePinnedImage( $site, $image ) + { + $this->assertFileContains( EE_SITE_ROOT . "/$site/docker-compose.yml", 'image: ' . $image . ':' . $this->pinnedTag( $image ), false ); + } + + /** + * @Then the :service service of site :site should have the restart policy :policy + */ + public function theServiceShouldHaveRestartPolicy( $service, $site, $policy ) + { + $id = $this->requireServiceContainer( $site, $service ); + $actual = trim( self::run( 'docker inspect -f {{.HostConfig.RestartPolicy.Name}} ' . $id )->stdout ); + if ( $actual !== $policy ) { + throw new Exception( "The $service service of $site has restart policy '$actual', expected '$policy'." ); + } + } + + /** + * @Given I note the start time of the :service service of site :site + */ + public function iNoteTheStartTimeOfTheService( $service, $site ) + { + $this->start_times[ "$site/$service" ] = $this->containerStartedAt( $this->requireServiceContainer( $site, $service ) ); + } + + /** + * @Then /^the "([^"]*)" service of site "([^"]*)" should( not|) have been restarted$/ + */ + public function theServiceShouldHaveBeenRestarted( $service, $site, $not ) + { + if ( ! isset( $this->start_times[ "$site/$service" ] ) ) { + throw new Exception( "Note the start time of the $service service of $site first." ); + } + $before = $this->start_times[ "$site/$service" ]; + $after = $this->containerStartedAt( $this->requireServiceContainer( $site, $service ) ); + $restarted = $before !== $after; + if ( $restarted === ( '' !== $not ) ) { + throw new Exception( "The $service service of $site started at $before and now $after." ); + } + } + + /** + * @Then the php service of site :site should run PHP :version + */ + public function thePhpServiceShouldRunPhp( $site, $version ) + { + $result = $this->execInService( $site, 'php', 'php -r \'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;\'' ); + if ( trim( $result->stdout ) !== $version ) { + throw new Exception( "The php service of $site runs PHP " . trim( $result->stdout ) . ", expected $version.\n" . $this->describe( $result ) ); + } + } + + /** + * Connects the way site code would (mysqli), with the credentials ee stored for the site. + * + * @Then /^the php service of site "([^"]*)" should( not|) be able to connect to the site database$/ + */ + public function thePhpServiceShouldConnectToTheSiteDatabase( $site, $not ) + { + $row = $this->requireSiteRow( $site ); + $code = sprintf( + 'connect_errno ? "ERR " . $m->connect_error : "OK";', + var_export( (string) $row['db_host'], true ), + var_export( (string) $row['db_user'], true ), + var_export( (string) $row['db_password'], true ), + var_export( (string) $row['db_name'], true ), + empty( $row['db_port'] ) ? 3306 : (int) $row['db_port'] + ); + $id = $this->requireServiceContainer( $site, 'php' ); + $deadline = time() + ( '' === $not ? 60 : 0 ); + // A fresh local MariaDB refuses connections until its first-start init is done. + do { + $result = self::run( sprintf( 'printf %%s %s | docker exec -i %s php', escapeshellarg( $code ), $id ) ); + $ok = 'OK' === trim( $result->stdout ); + if ( $ok || time() >= $deadline ) { + break; + } + sleep( 2 ); + } while ( true ); + if ( $ok === ( '' !== $not ) ) { + throw new Exception( sprintf( "The php service of %s %s connect to %s@%s/%s.\n%s", $site, $ok ? 'could' : 'could not', $row['db_user'], $row['db_host'], $row['db_name'], $this->describe( $result ) ) ); + } + } + + /** + * @Then /^the global database "([^"]*)" should( not|) exist$/ + */ + public function theGlobalDatabaseShouldExist( $db, $not ) + { + $found = trim( self::globalDbQuery( sprintf( "SELECT schema_name FROM information_schema.schemata WHERE schema_name = '%s'", addslashes( $db ) ), true )->stdout ); + $exists = $found === $db; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Global database $db " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Then /^the global database user "([^"]*)" should( not|) exist$/ + */ + public function theGlobalDatabaseUserShouldExist( $user, $not ) + { + $found = trim( self::globalDbQuery( sprintf( "SELECT user FROM mysql.user WHERE user = '%s'", addslashes( $user ) ), true )->stdout ); + $exists = $found === $user; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Global database user $user " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Given the global database :db with user :user identified by :password exists + */ + public function theGlobalDatabaseWithUserExists( $db, $user, $password ) + { + self::assertSuiteDbNames( $db, $user ); + $sql = sprintf( + "CREATE DATABASE IF NOT EXISTS `%1\$s`; CREATE USER IF NOT EXISTS '%2\$s'@'%%' IDENTIFIED BY '%3\$s'; GRANT ALL PRIVILEGES ON `%1\$s`.* TO '%2\$s'@'%%'; FLUSH PRIVILEGES;", + str_replace( '`', '', $db ), + addslashes( $user ), + addslashes( $password ) + ); + self::globalDbQuery( $sql, true ); + } + + /** + * @Given the global database user :user identified by :password exists + */ + public function theGlobalDatabaseUserExists( $user, $password ) + { + self::assertSuiteDbNames( null, $user ); + self::globalDbQuery( sprintf( "CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'; FLUSH PRIVILEGES;", addslashes( $user ), addslashes( $password ) ), true ); + } + + /** + * @Given the global database :db contains the table :table + */ + public function theGlobalDatabaseContainsTheTable( $db, $table ) + { + self::assertSuiteDbNames( $db, null ); + self::globalDbQuery( sprintf( 'CREATE TABLE IF NOT EXISTS `%s`.`%s` (id INT PRIMARY KEY)', str_replace( '`', '', $db ), str_replace( '`', '', $table ) ), true ); + } + + /** + * @Then /^the global database "([^"]*)" should( not|) contain the table "([^"]*)"$/ + */ + public function theGlobalDatabaseShouldContainTheTable( $db, $not, $table ) + { + $found = trim( self::globalDbQuery( sprintf( "SELECT table_name FROM information_schema.tables WHERE table_schema = '%s' AND table_name = '%s'", addslashes( $db ), addslashes( $table ) ), true )->stdout ); + $exists = $found === $table; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Table $db.$table " . ( $exists ? 'exists.' : 'does not exist.' ) ); + } + } + + /** + * @Then /^the database "([^"]*)" should( not|) exist in the db service of site "([^"]*)"$/ + */ + public function theDatabaseShouldExistInTheDbService( $db, $not, $site ) + { + $query = sprintf( 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e %s', escapeshellarg( sprintf( "SELECT schema_name FROM information_schema.schemata WHERE schema_name = '%s'", addslashes( $db ) ) ) ); + $deadline = time() + 60; + // Until its first-start init is done, a fresh local MariaDB denies root with the configured password. + while ( 0 !== ( $result = $this->execInService( $site, 'db', $query ) )->return_code && time() < $deadline ) { + sleep( 2 ); + } + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not query the db service of $site.\n" . $this->describe( $result ) ); + } + $exists = trim( $result->stdout ) === $db; + if ( $exists === ( '' !== $not ) ) { + throw new Exception( "Database $db " . ( $exists ? 'exists' : 'does not exist' ) . " in the db service of $site." ); + } + } + + /** + * @Then /^the global redis should( not|) have keys matching "([^"]*)"$/ + */ + public function theGlobalRedisShouldHaveKeysMatching( $not, $pattern ) + { + $result = $this->globalRedis( '--scan --pattern ' . escapeshellarg( $pattern ) ); + $this->assertKeys( 'global redis', $result, $pattern, '' !== $not ); + } + + /** + * @Then /^the redis service of site "([^"]*)" should( not|) have keys matching "([^"]*)"$/ + */ + public function theSiteRedisShouldHaveKeysMatching( $site, $not, $pattern ) + { + $result = $this->execInService( $site, 'redis', 'redis-cli --scan --pattern ' . escapeshellarg( $pattern ) ); + $this->assertKeys( "redis service of $site", $result, $pattern, '' !== $not ); + } + + /** + * @When /^I send a GET request to "([^"]*)"$/ + */ + public function iSendAGetRequestTo( $url ) + { + $this->response = $this->httpRequest( $url ); + } + + /** + * @When /^I send a GET request to "([^"]*)" via "([^"]*)"$/ + */ + public function iSendAGetRequestVia( $url, $ip ) + { + $this->response = $this->httpRequest( $url, [ 'resolve' => $ip ] ); + } + + /** + * @When /^I send a GET request to "([^"]*)" with Host "([^"]*)"$/ + */ + public function iSendAGetRequestWithHost( $url, $host ) + { + $this->response = $this->httpRequest( $url, [ 'host' => $host ] ); + } + + /** + * @Then /^a GET request to "([^"]*)" should return (\d{3})$/ + */ + public function aGetRequestShouldReturn( $url, $code ) + { + $this->response = $this->httpRequest( $url ); + // ee returns before nginx-proxy's last `nginx -s reload` takes effect, so the first request can still hit the old config (500/502). + $first = $this->response['code']; + $started = time(); + $deadline = $started + 15; + while ( (int) $code < 500 && ( $this->response['code'] >= 500 || 0 === $this->response['code'] ) && time() < $deadline ) { + sleep( 1 ); + $this->response = $this->httpRequest( $url ); + } + if ( $first !== $this->response['code'] ) { + echo "First answer was HTTP $first; HTTP {$this->response['code']} after " . ( time() - $started ) . " s.\n"; + } + $this->theResponseCodeShouldBe( $code ); + } + + /** + * @Then /^a GET request to "([^"]*)" via "([^"]*)" should( not|) return (\d{3})$/ + */ + public function aGetRequestViaShouldReturn( $url, $ip, $not, $code ) + { + $this->response = $this->httpRequest( $url, [ 'resolve' => $ip ] ); + if ( '' === $not ) { + $this->theResponseCodeShouldBe( $code ); + } elseif ( (int) $code === $this->response['code'] ) { + throw new Exception( "Expected anything but HTTP $code.\n" . $this->describeResponse() ); + } + } + + /** + * @Then /^a GET request to "([^"]*)" with Host "([^"]*)" should return (\d{3})$/ + */ + public function aGetRequestWithHostShouldReturn( $url, $host, $code ) + { + $this->response = $this->httpRequest( $url, [ 'host' => $host ] ); + $this->theResponseCodeShouldBe( $code ); + } + + /** + * @Then /^a GET request to "([^"]*)" with certificate verification should return (\d{3})$/ + */ + public function aVerifiedGetRequestShouldReturn( $url, $code ) + { + $this->response = $this->httpRequest( $url, [ 'verify' => true ] ); + $this->theResponseCodeShouldBe( $code ); + } + + /** + * @When /^I wait until a GET request to "([^"]*)"(?: via "([^"]*)")? (returns|no longer returns) (\d{3}) within (\d+) seconds$/ + */ + public function iWaitUntilAGetRequestReturns( $url, $ip, $mode, $code, $seconds ) + { + $options = '' === $ip ? [] : [ 'resolve' => $ip ]; + $want = 'returns' === $mode; + $deadline = time() + (int) $seconds; + do { + $this->response = $this->httpRequest( $url, $options ); + if ( ( (int) $code === $this->response['code'] ) === $want ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "GET $url still did not satisfy '$mode $code' after $seconds seconds.\n" . $this->describeResponse() ); + } + + /** + * @Then the response code should be :code + */ + public function theResponseCodeShouldBe( $code ) + { + if ( null === $this->response ) { + throw new Exception( 'No request was sent.' ); + } + if ( (int) $code !== $this->response['code'] ) { + throw new Exception( "Expected HTTP $code.\n" . $this->describeResponse() ); + } + } + + /** + * @Then /^the response should( not|) contain "([^"]*)"$/ + */ + public function theResponseShouldContain( $not, $text ) + { + $found = false !== strpos( $this->requireResponse()['body'], $text ); + if ( $found === ( '' !== $not ) ) { + throw new Exception( sprintf( "The response body %s %s.\n%s", $found ? 'contains' : 'does not contain', $text, $this->describeResponse() ) ); + } + } + + /** + * @Then /^the response header "([^"]*)" should( not|) contain "([^"]*)"$/ + */ + public function theResponseHeaderShouldContain( $header, $not, $text ) + { + $value = $this->responseHeader( $header ); + $found = null !== $value && false !== strpos( $value, $text ); + if ( $found === ( '' !== $not ) ) { + throw new Exception( sprintf( "Header %s is %s.\n%s", $header, var_export( $value, true ), $this->describeResponse() ) ); + } + } + + /** + * @Then /^the response should( not|) have the header "([^"]*)"$/ + */ + public function theResponseShouldHaveTheHeader( $not, $header ) + { + $present = null !== $this->responseHeader( $header ); + if ( $present === ( '' !== $not ) ) { + throw new Exception( sprintf( "Header %s is %s.\n%s", $header, $present ? 'present' : 'missing', $this->describeResponse() ) ); + } + } + + /** + * @Given a self-signed certificate for :domain exists in :dir + */ + public function aSelfSignedCertificateExists( $domain, $dir ) + { + if ( ! is_dir( $dir ) ) { + mkdir( $dir, 0700, true ); + } + $cmd = sprintf( + 'openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj %s -addext %s -keyout %s -out %s', + escapeshellarg( '/CN=' . $domain ), + escapeshellarg( 'subjectAltName=DNS:' . $domain ), + escapeshellarg( "$dir/$domain.key" ), + escapeshellarg( "$dir/$domain.crt" ) + ); + $result = self::run( $cmd ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not create a certificate for $domain.\n" . $this->describe( $result ) ); + } + } + + /** + * @Then the certificate served for :host should match the file :path + */ + public function theCertificateServedShouldMatchTheFile( $host, $path ) + { + $served = openssl_x509_fingerprint( $this->servedCertificate( $host ), 'sha256' ); + $expected = openssl_x509_fingerprint( $this->readFile( $path ), 'sha256' ); + if ( $served !== $expected ) { + throw new Exception( "The certificate served for $host ($served) is not the one in $path ($expected)." ); + } + } + + /** + * @Then the certificate served for :host should cover :name + */ + public function theCertificateServedShouldCover( $host, $name ) + { + $info = openssl_x509_parse( $this->servedCertificate( $host ) ); + $sans = isset( $info['extensions']['subjectAltName'] ) ? $info['extensions']['subjectAltName'] : ''; + $names = array_map( function ( $entry ) { + return trim( str_replace( 'DNS:', '', $entry ) ); + }, explode( ',', $sans ) ); + if ( ! in_array( $name, $names, true ) ) { + throw new Exception( "The certificate served for $host covers " . implode( ', ', $names ) . ", not $name." ); + } + } + + /** + * CI runs @le against Pebble and sets EE_BEHAT_LE_ISSUER to its intermediate CN. + * + * @Then the certificate served for :host should be issued by the ACME CA + */ + public function theCertificateServedShouldBeIssuedByTheAcmeCa( $host ) + { + $this->theCertificateServedShouldBeIssuedBy( $host, getenv( 'EE_BEHAT_LE_ISSUER' ) ?: "Let's Encrypt" ); + } + + /** + * @Then the certificate served for :host should be issued by :issuer + */ + public function theCertificateServedShouldBeIssuedBy( $host, $issuer ) + { + $info = openssl_x509_parse( $this->servedCertificate( $host ) ); + $actual = isset( $info['issuer'] ) ? implode( ', ', (array) $info['issuer'] ) : ''; + if ( false === stripos( $actual, $issuer ) ) { + throw new Exception( "The certificate served for $host is issued by $actual, not $issuer." ); + } + } + + /** + * @Then /^the hosts file should( not|) have an entry for "([^"]*)"$/ + */ + public function theHostsFileShouldHaveAnEntryFor( $not, $host ) + { + $found = 1 === preg_match( '/^\s*[0-9a-f:.]+\s+(.*\s)?' . preg_quote( $host, '/' ) . '(\s|$)/mi', $this->readFile( '/etc/hosts' ) ); + if ( $found === ( '' !== $not ) ) { + throw new Exception( "/etc/hosts " . ( $found ? 'has' : 'has no' ) . " entry for $host." ); + } + } + + /** + * Adds a marked line that the suite cleanup removes again. + * + * @Given the hosts file maps :host to :ip + */ + public function theHostsFileMaps( $host, $ip ) + { + $hosts = $this->readFile( '/etc/hosts' ); + if ( 1 !== preg_match( '/^\s*' . preg_quote( $ip, '/' ) . '\s+(.*\s)?' . preg_quote( $host, '/' ) . '(\s|$)/m', $hosts ) ) { + file_put_contents( '/etc/hosts', rtrim( $hosts, "\n" ) . "\n$ip\t$host " . self::HOSTS_MARKER . "\n" ); + } + } + + /** + * EE_BEHAT_LE_DNS_SERVER overrides the resolver (CI points the test names at Pebble's DNS). + * + * @Given the domain :domain resolves publicly + */ + public function theDomainResolvesPublicly( $domain ) + { + $server = getenv( 'EE_BEHAT_LE_DNS_SERVER' ) ?: '1.1.1.1'; + $result = self::run( 'dig +short ' . escapeshellarg( $domain ) . ' ' . escapeshellarg( '@' . $server ) ); + if ( '' === trim( $result->stdout ) ) { + throw new Exception( "$domain does not resolve publicly; Let's Encrypt scenarios need the ee4-test.rt.gw DNS records." ); + } + } + + /** + * Commands run with stdin on /dev/null, so a value ee would prompt for (e.g. le-mail) must already be configured. + * + * @Given the ee config has a :key value + */ + public function theEeConfigHasAValue( $key ) + { + $config = $this->readFile( EE_ROOT_DIR . '/config/config.yml' ); + if ( 1 !== preg_match( '/^' . preg_quote( $key, '/' ) . ':[ \t]*\S/m', $config ) ) { + throw new Exception( "Set $key in " . EE_ROOT_DIR . '/config/config.yml first (ee would prompt for it).' ); + } + } + + /** + * Creates the site only if it does not exist yet, so scenarios can run alone or reuse a site made earlier in the feature. + * + * @Given a php site :site exists + */ + public function aPhpSiteExists( $site ) + { + $this->aPhpSiteCreatedWithFlagsExists( $site, '' ); + } + + /** + * @Given a php site :site created with :flags exists + */ + public function aPhpSiteCreatedWithFlagsExists( $site, $flags ) + { + if ( null !== $this->siteRow( $site ) ) { + return; + } + $result = self::run( sprintf( 'bin/ee site create %s --type=php %s', escapeshellarg( $site ), $flags ) ); + if ( 0 !== $result->return_code || null === $this->siteRow( $site ) ) { + throw new Exception( "Could not create php site $site ($flags).\n" . $this->describe( $result ) ); + } + } + + /** + * @Given the site :site has the alias domain :alias + */ + public function theSiteHasTheAliasDomain( $site, $alias ) + { + $row = $this->requireSiteRow( $site ); + if ( in_array( $alias, explode( ',', (string) $row['alias_domains'] ), true ) ) { + return; + } + $result = self::run( sprintf( 'bin/ee site update %s --add-alias-domains=%s', escapeshellarg( $site ), escapeshellarg( $alias ) ) ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not add alias $alias to $site.\n" . $this->describe( $result ) ); + } + } + + /** + * @Given the site :site is disabled + */ + public function theSiteIsDisabled( $site ) + { + $row = $this->requireSiteRow( $site ); + if ( 0 === (int) $row['site_enabled'] ) { + return; + } + $result = self::run( 'bin/ee site disable ' . escapeshellarg( $site ) ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not disable $site.\n" . $this->describe( $result ) ); + } + } + + /** + * @Given the site :site does not exist + */ + public function theSiteDoesNotExist( $site ) + { + if ( null === $this->siteRow( $site ) ) { + return; + } + if ( 1 !== preg_match( self::SITE_PATTERN, $site ) ) { + throw new Exception( "Refusing to delete $site: it is not a site of this suite." ); + } + $result = self::run( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes' ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not delete $site.\n" . $this->describe( $result ) ); + } + } + + /* + * ----- helpers ----- + */ + + /** + * Runs a shell command with stdin on /dev/null, so docker/compose never allocate a TTY: under the harness's + * nested ptys a TTY exec can hang, and without one `docker exec -it` fails fast instead. + * + * @return object command, stdout, stderr, return_code (same fields as EE's ProcessRun). + */ + private static function run( $command, $cwd = null ) + { + $out = tempnam( sys_get_temp_dir(), 'bhstp' ); + $err = tempnam( sys_get_temp_dir(), 'bhstp' ); + $proc = proc_open( $command, [ 0 => [ 'file', '/dev/null', 'r' ], 1 => [ 'file', $out, 'w' ], 2 => [ 'file', $err, 'w' ] ], $pipes, $cwd ); + $code = is_resource( $proc ) ? proc_close( $proc ) : -1; + $result = (object) [ + 'command' => $command, + 'stdout' => (string) file_get_contents( $out ), + 'stderr' => (string) file_get_contents( $err ), + 'return_code' => $code, + ]; + unlink( $out ); + unlink( $err ); + + return $result; + } + + private function lastCommand() + { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command was run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function commandAt( $index ) + { + if ( ! isset( $this->commands[ $index - 1 ] ) ) { + throw new Exception( "Command $index was not run in this scenario." ); + } + + return $this->commands[ $index - 1 ]; + } + + private function streamOf( $result, $stream ) + { + return 'STDOUT' === $stream ? $result->stdout : $result->stderr; + } + + private function describe( $result ) + { + return sprintf( "Command: %s\nReturn code: %s\nSTDOUT:\n%s\nSTDERR:\n%s", $result->command, $result->return_code, $result->stdout, $result->stderr ); + } + + private function assertReturnCode( $code ) + { + $result = $this->lastCommand(); + if ( $code !== $result->return_code ) { + throw new Exception( "Expected return code $code.\n" . $this->describe( $result ) ); + } + } + + private function assertStreamContains( $stream, $text, $negate ) + { + $result = $this->lastCommand(); + $output = str_replace( [ "\033[1;31m", "\033[0m" ], '', $this->streamOf( $result, $stream ) ); + $found = false !== strpos( $output, $text ); + if ( $found === $negate ) { + throw new Exception( sprintf( "%s %s:\n%s\n\n%s", $stream, $negate ? 'unexpectedly contains' : 'does not contain', $text, $this->describe( $result ) ) ); + } + } + + private function decodedStdout() + { + $stdout = trim( $this->lastCommand()->stdout ); + $data = json_decode( $stdout, true ); + if ( null === $data && 'null' !== $stdout ) { + throw new Exception( 'STDOUT is not valid JSON (' . json_last_error_msg() . "):\n" . $stdout ); + } + + return $data; + } + + private function jsonPath( $data, $path ) + { + foreach ( explode( '.', $path ) as $part ) { + if ( ! is_array( $data ) || ! array_key_exists( $part, $data ) ) { + throw new Exception( "JSON key $path not found in:\n" . $this->lastCommand()->stdout ); + } + $data = $data[ $part ]; + } + + return $data; + } + + private function scalarToString( $value ) + { + if ( null === $value || false === $value ) { + return ''; + } + if ( true === $value ) { + return '1'; + } + + return (string) $value; + } + + private function lines( $text ) + { + return array_values( array_filter( array_map( 'trim', explode( "\n", trim( $text ) ) ), 'strlen' ) ); + } + + private function readFile( $path ) + { + clearstatcache(); + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + + return file_get_contents( $path ); + } + + private function assertFileContains( $path, $text, $negate ) + { + $content = $this->readFile( $path ); + $found = false !== strpos( $content, $text ); + if ( $found === $negate ) { + throw new Exception( sprintf( "%s %s:\n%s\n\nContent:\n%s", $path, $negate ? 'unexpectedly contains' : 'does not contain', $text, $content ) ); + } + } + + /** + * @return array|null The site's row from ee.sqlite, read-only. + */ + private function siteRow( $site ) + { + $rows = self::sqliteRows( 'SELECT * FROM sites WHERE site_url = :site', [ ':site' => $site ] ); + + return empty( $rows ) ? null : $rows[0]; + } + + private function requireSiteRow( $site, $field = null ) + { + $row = $this->siteRow( $site ); + if ( null === $row ) { + throw new Exception( "Site $site has no row in the sites table." ); + } + if ( null !== $field && ! array_key_exists( $field, $row ) ) { + throw new Exception( "The sites table has no column $field." ); + } + + return $row; + } + + private static function sqliteRows( $sql, array $params = [] ) + { + $path = EE_ROOT_DIR . '/db/ee.sqlite'; + if ( ! file_exists( $path ) ) { + return []; + } + $db = new SQLite3( $path, SQLITE3_OPEN_READONLY ); + $db->busyTimeout( 5000 ); + $stmt = $db->prepare( $sql ); + foreach ( $params as $key => $value ) { + $stmt->bindValue( $key, $value, SQLITE3_TEXT ); + } + $result = $stmt->execute(); + $rows = []; + while ( $row = $result->fetchArray( SQLITE3_ASSOC ) ) { + $rows[] = $row; + } + $db->close(); + + return $rows; + } + + private function serviceContainerId( $site, $service, $running_only = true ) + { + $cmd = sprintf( + 'docker ps %s -q -f %s -f %s', + $running_only ? '' : '-a', + escapeshellarg( 'label=io.easyengine.site=' . $site ), + escapeshellarg( 'label=com.docker.compose.service=' . $service ) + ); + + return implode( ' ', $this->lines( self::run( $cmd )->stdout ) ); + } + + private function requireServiceContainer( $site, $service ) + { + $id = $this->serviceContainerId( $site, $service ); + if ( '' === $id || false !== strpos( $id, ' ' ) ) { + throw new Exception( "Expected exactly one running $service container for $site, found '$id'.\n" . $this->siteContainersSummary( $site ) ); + } + + return $id; + } + + private function execInService( $site, $service, $command ) + { + $id = $this->requireServiceContainer( $site, $service ); + + return self::run( sprintf( 'docker exec %s sh -c %s', $id, escapeshellarg( $command ) ) ); + } + + private function siteContainersSummary( $site ) + { + return self::run( 'docker ps -a -f ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) . ' --format ' . escapeshellarg( '{{.Names}} {{.Status}} {{.Image}}' ) )->stdout; + } + + private function siteVolumes( $site ) + { + return $this->lines( self::run( 'docker volume ls -q -f label=org.label-schema.vendor=EasyEngine -f ' . escapeshellarg( 'label=io.easyengine.site=' . $site ) )->stdout ); + } + + private function containerStartedAt( $id ) + { + return trim( self::run( 'docker inspect -f {{.State.StartedAt}} ' . $id )->stdout ); + } + + private function pinnedTag( $image ) + { + $versions = json_decode( file_get_contents( EE_ROOT . '/img-versions.json' ), true ); + if ( empty( $versions[ $image ] ) ) { + throw new Exception( "img-versions.json has no tag for $image." ); + } + + return $versions[ $image ]; + } + + private function globalRedis( $args ) + { + return self::run( sprintf( 'docker exec %s redis-cli %s', self::GLOBAL_REDIS_CONTAINER, $args ) ); + } + + private function assertKeys( $where, $result, $pattern, $negate ) + { + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not scan the $where.\n" . $this->describe( $result ) ); + } + $keys = $this->lines( $result->stdout ); + if ( empty( $keys ) !== $negate ) { + throw new Exception( sprintf( "The %s has %d keys matching %s:\n%s", $where, count( $keys ), $pattern, implode( "\n", $keys ) ) ); + } + } + + /** + * Setup steps write to global-db as root: keep them on the suite's own names. + */ + private static function assertSuiteDbNames( $db, $user ) + { + if ( null !== $db && 1 !== preg_match( '/^(bh_stp_|bh_site_type_php_)[A-Za-z0-9_]+$/', $db ) ) { + throw new Exception( "Refusing to touch the database $db: not a name of this suite." ); + } + if ( null !== $user && 1 !== preg_match( '/^(bh_stp_[A-Za-z0-9_]+|bh-site-type-php-[A-Za-z0-9.-]+)$/', $user ) ) { + throw new Exception( "Refusing to touch the database user $user: not a name of this suite." ); + } + } + + private static function globalDbQuery( $sql, $throw = false ) + { + $script = 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e ' . escapeshellarg( $sql ); + $result = self::run( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_DB_CONTAINER, escapeshellarg( $script ) ) ); + if ( $throw && 0 !== $result->return_code ) { + throw new Exception( "Global db query failed: $sql\n{$result->stderr}" ); + } + + return $result; + } + + /** + * @param array $options resolve: IP to pin the URL's host to (keeps SNI), host: Host header, verify: check TLS. + */ + private function httpRequest( $url, array $options = [] ) + { + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $url ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HEADER, true ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + $verify = ! empty( $options['verify'] ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, $verify ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, $verify ? 2 : 0 ); + if ( ! empty( $options['resolve'] ) ) { + $parts = parse_url( $url ); + $port = isset( $parts['port'] ) ? $parts['port'] : ( 'https' === $parts['scheme'] ? 443 : 80 ); + curl_setopt( $ch, CURLOPT_RESOLVE, [ "{$parts['host']}:$port:{$options['resolve']}" ] ); + } + if ( ! empty( $options['host'] ) ) { + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 'Host: ' . $options['host'] ] ); + } + $raw = curl_exec( $ch ); + $code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $header_size = (int) curl_getinfo( $ch, CURLINFO_HEADER_SIZE ); + $error = curl_error( $ch ); + curl_close( $ch ); + $raw = false === $raw ? '' : $raw; + + return [ + 'url' => $url, + 'code' => $code, + 'headers' => substr( $raw, 0, $header_size ), + 'body' => (string) substr( $raw, $header_size ), + 'error' => $error, + ]; + } + + private function requireResponse() + { + if ( null === $this->response ) { + throw new Exception( 'No request was sent.' ); + } + + return $this->response; + } + + private function responseHeader( $name ) + { + foreach ( explode( "\n", $this->requireResponse()['headers'] ) as $line ) { + $pos = strpos( $line, ':' ); + if ( false !== $pos && 0 === strcasecmp( trim( substr( $line, 0, $pos ) ), $name ) ) { + return trim( substr( $line, $pos + 1 ) ); + } + } + + return null; + } + + private function describeResponse() + { + $r = $this->requireResponse(); + + return sprintf( "GET %s -> %d %s\nHeaders:\n%s\nBody (first 500 bytes):\n%s", $r['url'], $r['code'], $r['error'], trim( $r['headers'] ), substr( $r['body'], 0, 500 ) ); + } + + /** + * @return string PEM of the certificate nginx-proxy serves for $host on 127.0.0.1:443 (SNI = $host). + */ + private function servedCertificate( $host ) + { + $context = stream_context_create( [ + 'ssl' => [ + 'peer_name' => $host, + 'SNI_enabled' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + 'capture_peer_cert' => true, + ], + ] ); + $stream = @stream_socket_client( 'ssl://127.0.0.1:443', $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $context ); + if ( ! $stream ) { + throw new Exception( "TLS connection for $host failed: $errstr" ); + } + $params = stream_context_get_params( $stream ); + fclose( $stream ); + openssl_x509_export( $params['options']['ssl']['peer_certificate'], $pem ); + + return $pem; + } + + /** + * Removes everything this suite may leave behind, touching only names that match SITE_PATTERN / the bh_stp_ prefixes. + */ + private static function cleanupTestState() + { + $ee = escapeshellarg( realpath( EE_ROOT ) . '/bin/ee' ); + $sites = []; + foreach ( self::sqliteRows( 'SELECT site_url FROM sites' ) as $row ) { + if ( 1 === preg_match( self::SITE_PATTERN, $row['site_url'] ) ) { + $sites[] = $row['site_url']; + } + } + // Children (e.g. --ssl=inherit subdomains) first. + usort( $sites, function ( $a, $b ) { + return substr_count( $b, '.' ) - substr_count( $a, '.' ); + } ); + foreach ( $sites as $site ) { + // --foreground: in a background process group the `docker exec -it` of a cache purge stops on SIGTTOU (with timeout itself) and hangs forever. + self::run( sprintf( 'timeout --foreground -k 30 600 %s site delete %s --yes', $ee, escapeshellarg( $site ) ), realpath( EE_ROOT ) ); + } + + $remaining = array_column( self::sqliteRows( 'SELECT site_url FROM sites' ), 'site_url' ); + $orphan = function ( $name ) use ( $remaining ) { + return 1 === preg_match( self::SITE_PATTERN, $name ) && ! in_array( $name, $remaining, true ); + }; + + // Orphaned containers, networks and volumes of sites that no longer exist. + $containers = self::run( 'docker ps -a -f label=io.easyengine.site --format ' . escapeshellarg( '{{.ID}} {{.Label "io.easyengine.site"}}' ) )->stdout; + foreach ( array_filter( explode( "\n", $containers ) ) as $line ) { + list( $id, $label ) = array_pad( explode( ' ', trim( $line ), 2 ), 2, '' ); + if ( $orphan( $label ) ) { + self::run( 'docker rm -f ' . escapeshellarg( $id ) ); + } + } + $networks = self::run( 'docker network ls --format {{.Name}}' )->stdout; + foreach ( array_filter( explode( "\n", $networks ) ) as $network ) { + if ( $orphan( trim( $network ) ) ) { + self::run( 'docker network rm ' . escapeshellarg( trim( $network ) ) ); + } + } + $volumes = self::run( 'docker volume ls -f label=io.easyengine.site --format ' . escapeshellarg( '{{.Name}} {{.Label "io.easyengine.site"}}' ) )->stdout; + foreach ( array_filter( explode( "\n", $volumes ) ) as $line ) { + list( $name, $label ) = array_pad( explode( ' ', trim( $line ), 2 ), 2, '' ); + if ( $orphan( $label ) ) { + self::run( 'docker volume rm ' . escapeshellarg( $name ) ); + } + } + + // Site roots and proxy files left by failed creates. + foreach ( (array) glob( EE_SITE_ROOT . '/*' ) as $dir ) { + $name = basename( $dir ); + // Any name, so a dir left by the shell-syntax site name scenario's `$(...)` site name goes too. + if ( $orphan( $name ) ) { + self::run( 'rm -rf -- ' . escapeshellarg( $dir ) ); + } + } + $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; + $reload = false; + foreach ( [ 'conf.d', 'vhost.d', 'certs', 'htpasswd' ] as $sub ) { + foreach ( (array) glob( "$proxy_dir/$sub/*bh-site-type-php-*" ) as $file ) { + $site = preg_replace( '/(-redirect\.conf|\.conf|_location|\.crt|\.key|\.chain\.pem|\.dhparam\.pem)$/', '', basename( $file ) ); + if ( is_file( $file ) && $orphan( $site ) ) { + unlink( $file ); + $reload = true; + } + } + } + if ( $reload ) { + self::run( 'docker exec ' . self::PROXY_CONTAINER . ' sh -c ' . escapeshellarg( 'nginx -t && nginx -s reload' ) ); + } + + // Global-db databases and users created by the suite. Names still referenced by any remaining site row (another + // site, or a suite site whose delete failed) are never dropped, whatever their prefix. + if ( 0 === self::run( 'docker inspect ' . self::GLOBAL_DB_CONTAINER )->return_code ) { + $in_use = self::sqliteRows( "SELECT db_name, db_user FROM sites WHERE db_name != '' OR db_user != ''" ); + $used_dbs = array_filter( array_column( $in_use, 'db_name' ), 'strlen' ); + $used_users = array_filter( array_column( $in_use, 'db_user' ), 'strlen' ); + $where_db = implode( ' OR ', array_map( function ( $like ) { + return "schema_name LIKE '$like'"; + }, self::DB_NAME_LIKE ) ); + $where_user = implode( ' OR ', array_map( function ( $like ) { + return "user LIKE '$like'"; + }, self::DB_USER_LIKE ) ); + foreach ( array_filter( explode( "\n", self::globalDbQuery( "SELECT schema_name FROM information_schema.schemata WHERE $where_db" )->stdout ) ) as $db ) { + $db = trim( $db ); + if ( '' !== $db && ! in_array( $db, $used_dbs, true ) && false === strpos( $db, '`' ) ) { + self::globalDbQuery( sprintf( 'DROP DATABASE IF EXISTS `%s`', $db ) ); + } + } + foreach ( array_filter( explode( "\n", self::globalDbQuery( "SELECT CONCAT(user, '@', host) FROM mysql.user WHERE $where_user" )->stdout ) ) as $user_host ) { + list( $user, $host ) = array_pad( explode( '@', trim( $user_host ), 2 ), 2, '' ); + if ( '' !== $user && ! in_array( $user, $used_users, true ) ) { + self::globalDbQuery( sprintf( "DROP USER IF EXISTS '%s'@'%s'", addslashes( $user ), addslashes( $host ) ) ); + } + } + } + + // Page-cache keys of the suite's sites live for 4 hours and would let a later run's cache checks pass on old keys. + if ( 0 === self::run( 'docker inspect ' . self::GLOBAL_REDIS_CONTAINER )->return_code ) { + foreach ( [ 'bh-site-type-php-*', 'behat-bh-site-type-php-*' ] as $pattern ) { + $script = sprintf( 'redis-cli --scan --pattern %s | while IFS= read -r k; do redis-cli del "$k" >/dev/null; done', escapeshellarg( $pattern ) ); + self::run( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( $script ) ) ); + } + } + + // The shell-syntax site name payload can run again when that site is deleted above. + if ( file_exists( EE_ROOT . '/bhstpsec1' ) ) { + unlink( EE_ROOT . '/bhstpsec1' ); + } + + // Hosts entries for this suite's names (ee's own and the ones added by `the hosts file maps ...`). A line is only + // dropped when every host name on it belongs to the suite. + if ( is_writable( '/etc/hosts' ) ) { + $hosts = file_get_contents( '/etc/hosts' ); + $kept = array_filter( explode( "\n", $hosts ), function ( $line ) { + if ( false !== strpos( $line, self::HOSTS_MARKER ) ) { + return false; + } + $fields = preg_split( '/\s+/', trim( preg_replace( '/#.*/', '', $line ) ) ); + $names = array_slice( $fields, 1 ); + if ( empty( $names ) ) { + return true; + } + foreach ( $names as $name ) { + if ( 1 !== preg_match( self::SITE_PATTERN, $name ) ) { + return true; + } + } + + return false; + } ); + $new = implode( "\n", $kept ); + if ( rtrim( $new, "\n" ) !== rtrim( $hosts, "\n" ) ) { + file_put_contents( '/etc/hosts', rtrim( $new, "\n" ) . "\n" ); + } + } + } } diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index 7f1d528..e39d6b3 100644 --- a/features/bootstrap/support.php +++ b/features/bootstrap/support.php @@ -81,13 +81,27 @@ function compareContents( $expected, $actual ) { if ( is_object( $expected ) ) { foreach ( get_object_vars( $expected ) as $name => $value ) { - if ( ! compareContents( $value, $actual->$name ) ) { + if ( ! property_exists( $actual, $name ) || ! compareContents( $value, $actual->$name ) ) { + return false; + } + } + } else if ( is_array( $expected ) && array_values( $expected ) === $expected ) { + // Lists match by membership, so [1,3,5] is contained in [1,2,3,4,5]. + foreach ( $expected as $value ) { + $found = false; + foreach ( $actual as $candidate ) { + if ( compareContents( $value, $candidate ) ) { + $found = true; + break; + } + } + if ( ! $found ) { return false; } } } else if ( is_array( $expected ) ) { foreach ( $expected as $key => $value ) { - if ( ! compareContents( $value, $actual[$key] ) ) { + if ( ! array_key_exists( $key, $actual ) || ! compareContents( $value, $actual[ $key ] ) ) { return false; } } diff --git a/features/cache.feature b/features/cache.feature new file mode 100644 index 0000000..b5f88fc --- /dev/null +++ b/features/cache.feature @@ -0,0 +1,116 @@ +Feature: Redis page cache of php sites (--cache, --with-local-redis) and ee site clean + + Scenario: --cache uses the global redis for the nginx page cache + When I run "bin/ee site create bh-site-type-php-30.test --type=php --cache" + Then the command should succeed + And STDOUT should contain "| Cache | Enabled" + And the site "bh-site-type-php-30.test" should have the field "cache_host" set to "global-redis" + And the site "bh-site-type-php-30.test" should have the field "cache_nginx_fullpage" set to "1" + And the site "bh-site-type-php-30.test" should have the field "cache_nginx_browser" set to "1" + And the site "bh-site-type-php-30.test" should have the field "cache_mysql_query" set to "1" + And the docker container "services_global-redis_1" should be running + + Scenario: The global-redis cache config is in nginx and compose + Given a php site "bh-site-type-php-30.test" created with "--cache" exists + Then the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain "upstream redis" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain "server global-redis:6379;" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain "# Redis NGINX CONFIGURATION" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should contain: + """ + set $key "bh-site-type-php-30.test:http$request_method$host$request_uri"; + """ + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/config/nginx/conf.d/main.conf" should not contain "# PHP NGINX CONFIGURATION" + And the file "/opt/easyengine/sites/bh-site-type-php-30.test/docker-compose.yml" should not contain "easyengine/redis" + And the "nginx" service of site "bh-site-type-php-30.test" should be attached to the docker network "ee-global-backend-network" + And the "php" service of site "bh-site-type-php-30.test" should be attached to the docker network "ee-global-backend-network" + And the "redis" service of site "bh-site-type-php-30.test" should not exist + And the site "bh-site-type-php-30.test" should have 3 running containers + + Scenario: Pages of a --cache site are stored in and served from the global redis + Given a php site "bh-site-type-php-30.test" created with "--cache" exists + When I send a GET request to "http://bh-site-type-php-30.test/" + And I send a GET request to "http://bh-site-type-php-30.test/" + Then the response code should be 200 + And the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the global redis should have keys matching "bh-site-type-php-30.test:*" + And Check global redis cache for 'bh-site-type-php-30.test' + + Scenario: Requests with a query string bypass the page cache + Given a php site "bh-site-type-php-30.test" created with "--cache" exists + When I send a GET request to "http://bh-site-type-php-30.test/?bh=1" + And I send a GET request to "http://bh-site-type-php-30.test/?bh=1" + Then the response code should be 200 + And the response header "X-SRCache-Fetch-Status" should contain "BYPASS" + + Scenario: --cache --with-local-redis runs a redis container for the site + When I run "bin/ee site create bh-site-type-php-31.test --type=php --cache --with-local-redis" + Then the command should succeed + And the site "bh-site-type-php-31.test" should have the field "cache_host" set to "redis" + And the site "bh-site-type-php-31.test" should have the field "cache_nginx_fullpage" set to "1" + And the "redis" service of site "bh-site-type-php-31.test" should be running + And the "redis" service of site "bh-site-type-php-31.test" should use the pinned "easyengine/redis" image + And the site "bh-site-type-php-31.test" should have 4 running containers + And the file "/opt/easyengine/sites/bh-site-type-php-31.test/config/nginx/conf.d/main.conf" should contain "server redis:6379;" + And the "nginx" service of site "bh-site-type-php-31.test" should not be attached to the docker network "ee-global-backend-network" + + Scenario: Pages of a local-redis site are cached in the site's own redis only + Given a php site "bh-site-type-php-31.test" created with "--cache --with-local-redis" exists + When I send a GET request to "http://bh-site-type-php-31.test/" + And I send a GET request to "http://bh-site-type-php-31.test/" + Then the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the redis service of site "bh-site-type-php-31.test" should have keys matching "bh-site-type-php-31.test:*" + And the global redis should not have keys matching "bh-site-type-php-31.test:*" + And Check local redis cache for 'bh-site-type-php-31.test' + + # Current behaviour: --with-local-redis only acts together with --cache. + Scenario: --with-local-redis without --cache enables no cache + When I run "bin/ee site create bh-site-type-php-32.test --type=php --with-local-redis" + Then the command should succeed + And the site "bh-site-type-php-32.test" should have an empty "cache_host" + And the site "bh-site-type-php-32.test" should have the field "cache_nginx_fullpage" set to "0" + And the "redis" service of site "bh-site-type-php-32.test" should not exist + And the file "/opt/easyengine/sites/bh-site-type-php-32.test/config/nginx/conf.d/main.conf" should not contain "upstream redis" + + Scenario: Local db and global redis + When I run "bin/ee site create bh-site-type-php-33.test --cache --type=php --local-db --with-db" + Then the command should succeed + And a GET request to "http://bh-site-type-php-33.test/" should return 200 + And the "db" service of site "bh-site-type-php-33.test" should be running + And the "redis" service of site "bh-site-type-php-33.test" should not exist + And the php service of site "bh-site-type-php-33.test" should be able to connect to the site database + And the "nginx" service of site "bh-site-type-php-33.test" should be attached to the docker network "ee-global-backend-network" + When I send a GET request to "http://bh-site-type-php-33.test/" + And I send a GET request to "http://bh-site-type-php-33.test/" + Then the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the global redis should have keys matching "bh-site-type-php-33.test:*" + + Scenario: Local redis and global db + When I run "bin/ee site create bh-site-type-php-37.test --cache --type=php --with-db --with-local-redis" + Then the command should succeed + And a GET request to "http://bh-site-type-php-37.test/" should return 200 + And the "redis" service of site "bh-site-type-php-37.test" should be running + And the "db" service of site "bh-site-type-php-37.test" should not exist + And the php service of site "bh-site-type-php-37.test" should be able to connect to the site database + When I send a GET request to "http://bh-site-type-php-37.test/" + And I send a GET request to "http://bh-site-type-php-37.test/" + Then the response header "X-SRCache-Fetch-Status" should contain "HIT" + And the redis service of site "bh-site-type-php-37.test" should have keys matching "bh-site-type-php-37.test:*" + And the global redis should not have keys matching "bh-site-type-php-37.test:*" + + Scenario: Local db and local redis + When I run "bin/ee site create bh-site-type-php-38.test --cache --type=php --local-db --with-db --with-local-redis" + Then the command should succeed + And a GET request to "http://bh-site-type-php-38.test/" should return 200 + And the site "bh-site-type-php-38.test" should have 5 running containers + And the site "bh-site-type-php-38.test" should have 11 docker volumes + And the php service of site "bh-site-type-php-38.test" should be able to connect to the site database + And Check local redis cache for 'bh-site-type-php-38.test' + + Scenario: ee site clean on a site without cache reports that no cache is enabled + Given a php site "bh-site-type-php-34.test" exists + When I run "timeout 120 bin/ee site clean bh-site-type-php-34.test" + Then the return code should be 1 + And STDERR should contain "Site object cache is not enabled. Site page cache is not enabled." + When I run "timeout 120 bin/ee site clean bh-site-type-php-34.test --proxy" + Then the return code should be 1 + And STDERR should contain "Proxy cache is not enabled on site." diff --git a/features/create.feature b/features/create.feature new file mode 100644 index 0000000..e41b53a --- /dev/null +++ b/features/create.feature @@ -0,0 +1,191 @@ +Feature: Create a plain php site + + Scenario: Create a php site with default options + When I run "bin/ee site create bh-site-type-php-1.test --type=php" + Then the command should succeed + And STDOUT should contain "Creating PHP site bh-site-type-php-1.test" + And STDOUT should contain "Success: Configuration files copied." + And STDOUT should contain "Checking and verifying site-up status" + And STDOUT should contain "Site entry created." + And STDOUT should contain "| Site | http://bh-site-type-php-1.test" + And STDOUT should contain "| Site Root | /opt/easyengine/sites/bh-site-type-php-1.test" + And STDOUT should not contain "DB Host" + And the site "bh-site-type-php-1.test" should exist + + Scenario: The site row holds the php defaults + Given a php site "bh-site-type-php-1.test" exists + Then the site "bh-site-type-php-1.test" should be enabled + And the site "bh-site-type-php-1.test" should have the field "site_type" set to "php" + And the site "bh-site-type-php-1.test" should have the field "app_sub_type" set to "php" + And the site "bh-site-type-php-1.test" should have the field "php_version" set to "8.4" + And the site "bh-site-type-php-1.test" should have the field "site_fs_path" set to "/opt/easyengine/sites/bh-site-type-php-1.test" + And the site "bh-site-type-php-1.test" should have the field "site_container_fs_path" set to "/var/www/htdocs" + And the site "bh-site-type-php-1.test" should have the field "alias_domains" set to "bh-site-type-php-1.test" + And the site "bh-site-type-php-1.test" should have the field "app_admin_email" set to "admin@bh-site-type-php-1.test" + And the site "bh-site-type-php-1.test" should have the field "cache_nginx_fullpage" set to "0" + And the site "bh-site-type-php-1.test" should have the field "cache_nginx_browser" set to "0" + And the site "bh-site-type-php-1.test" should have the field "cache_mysql_query" set to "0" + And the site "bh-site-type-php-1.test" should have the field "site_ssl_wildcard" set to "0" + And the site "bh-site-type-php-1.test" should have the field "proxy_cache" set to "off" + And the site "bh-site-type-php-1.test" should have an empty "site_ssl" + And the site "bh-site-type-php-1.test" should have an empty "cache_host" + And the site "bh-site-type-php-1.test" should have an empty "db_host" + And the site "bh-site-type-php-1.test" should have an empty "db_name" + And the site "bh-site-type-php-1.test" should have an empty "db_user" + And the site "bh-site-type-php-1.test" should have an empty "db_password" + + Scenario: The site files are generated from the php templates + Given a php site "bh-site-type-php-1.test" exists + Then the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/.env" should contain "VIRTUAL_HOST=bh-site-type-php-1.test" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/.env" should not contain "MYSQL_ROOT_PASSWORD" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "server_name bh-site-type-php-1.test;" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "server bh-site-type-php-1.test_php:9000;" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs;" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should contain "# PHP NGINX CONFIGURATION" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/conf.d/main.conf" should not contain "upstream redis" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/custom/user.conf" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/nginx/custom/admin-tools.conf" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/config/php/php/conf.d/custom.ini" should contain "sendmail_path = /usr/bin/msmtp -t" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs/index.php" should contain "Congratulations! Your PHP site with EasyEngine is working perfectly." + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs/index.php" should contain "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs" + And the directory "/opt/easyengine/sites/bh-site-type-php-1.test/app/html" should not exist + And the path "/opt/easyengine/sites/bh-site-type-php-1.test/app" should be a symlink + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/app/htdocs/check.html" should not exist + + Scenario: The compose file has the php services and no db or redis + Given a php site "bh-site-type-php-1.test" exists + Then the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/php8.4" image + And the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/nginx" image + And the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/postfix" image + And the docker-compose file of site "bh-site-type-php-1.test" should use the pinned "easyengine/mailhog" image + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should contain "VIRTUAL_HOST=${VIRTUAL_HOST}" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should contain "- ${VIRTUAL_HOST}_php" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should not contain "easyengine/mariadb" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should not contain "easyengine/redis" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should not contain "CERT_NAME" + And the file "/opt/easyengine/sites/bh-site-type-php-1.test/docker-compose.yml" should contain "HTTPS_METHOD=nohttps" + + Scenario: The site containers, volumes and network are up + Given a php site "bh-site-type-php-1.test" exists + Then the "php" service of site "bh-site-type-php-1.test" should be running + And the "nginx" service of site "bh-site-type-php-1.test" should be running + And the "postfix" service of site "bh-site-type-php-1.test" should be running + And the "mailhog" service of site "bh-site-type-php-1.test" should not be running + And the site "bh-site-type-php-1.test" should have 3 running containers + And the site "bh-site-type-php-1.test" should have 8 docker volumes + And the docker network "bh-site-type-php-1.test" should exist + And the "php" service of site "bh-site-type-php-1.test" should use the pinned "easyengine/php8.4" image + And the "nginx" service of site "bh-site-type-php-1.test" should be attached to the docker network "ee-global-frontend-network" + And the "php" service of site "bh-site-type-php-1.test" should not be attached to the docker network "ee-global-frontend-network" + And the "php" service of site "bh-site-type-php-1.test" should have the restart policy "always" + And the php service of site "bh-site-type-php-1.test" should run PHP "8.4" + + Scenario: The site is reachable over HTTP and runs PHP + Given a php site "bh-site-type-php-1.test" exists + Then the hosts file should have an entry for "bh-site-type-php-1.test" + And a GET request to "http://bh-site-type-php-1.test/" should return 200 + And the response should contain "Congratulations! Your PHP site with EasyEngine is working perfectly." + And the response should not have the header "X-SRCache-Fetch-Status" + When I write "" + Then the command should fail + And STDERR should contain "Invalid value specified for 'php'" + And the site "bh-site-type-php-9.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-9.test" should not exist + + Examples: + | version | + | 5.5 | + | 7.1 | + | 9.0 | + | abc | + + # EE3 compat: --mysql maps to --type=php --with-db (Site_Command::convert_old_args_to_new_args). + Scenario: The legacy --mysql flag creates a php site with a database + When I run "bin/ee site create bh-site-type-php-7.test --mysql" + Then the command should succeed + And the site "bh-site-type-php-7.test" should have the field "site_type" set to "php" + And the site "bh-site-type-php-7.test" should have the field "app_sub_type" set to "mysql" + And the site "bh-site-type-php-7.test" should have the field "db_host" set to "global-db" + + # Regression check (fixed in v4.13.2 by EasyEngine/site-type-php#117 and EasyEngine/site-command#509): the site name used to reach shell commands unescaped (volume commands run from the core root). The benign payload survives the lowercasing and writes ./bhstpsec1 there, which the scenario cleanup removes. + @security + Scenario: A site name with shell syntax is rejected and never executed + When I run "bin/ee site create 'bh-site-type-php-5$(id>bhstpsec1).test' --type=php" + Then the command should fail + And STDERR should contain "Invalid site name" + And the file "bhstpsec1" should not exist + + # Core's EE_DOCKER::get_docker_style_prefix() drops "." and "-" from volume names, so both names below map to the prefix bhsitetypephp27test; existing sites keep their volume names, so the second create is refused (EasyEngine/site-command#503). + Scenario: A site name whose docker volumes collide with an existing site is refused + Given a php site "bh-site-type-php-2-7.test" exists + When I run "bin/ee site create bh-site-type-php-27.test --type=php" + Then the command should fail + And STDERR should contain "Site bh-site-type-php-27.test would share docker volumes (bhsitetypephp27test_*) with the existing site bh-site-type-php-2-7.test." + And the site "bh-site-type-php-27.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-27.test" should not exist + And the site "bh-site-type-php-2-7.test" should have 8 docker volumes + And a GET request to "http://bh-site-type-php-2-7.test/" should return 200 + And the response should contain "/opt/easyengine/sites/bh-site-type-php-2-7.test/app/htdocs" diff --git a/features/database.feature b/features/database.feature new file mode 100644 index 0000000..2be067a --- /dev/null +++ b/features/database.feature @@ -0,0 +1,186 @@ +Feature: Database options of php sites (--with-db, --local-db, --dbhost, --dbname, --dbuser, --dbpass) + + Scenario: --with-db creates a database and user on the global db + When I run "bin/ee site create bh-site-type-php-10.test --type=php --with-db" + Then the command should succeed + And STDOUT should match: + """ + /\|\s*DB Host\s*\|\s*global-db\s*\|/ + """ + And STDOUT should match: + """ + /\|\s*DB Name\s*\|\s*bh_site_type_php_10_test\s*\|/ + """ + And STDOUT should not contain "DB Root Password" + And the site "bh-site-type-php-10.test" should have the field "app_sub_type" set to "mysql" + And the site "bh-site-type-php-10.test" should have the field "db_host" set to "global-db" + And the site "bh-site-type-php-10.test" should have the field "db_port" set to "3306" + And the site "bh-site-type-php-10.test" should have the field "db_name" set to "bh_site_type_php_10_test" + And the site "bh-site-type-php-10.test" should have the field "db_user" matching "/^bh-site-type-php-10\.test-[A-Za-z0-9]{6}$/" + And the site "bh-site-type-php-10.test" should have a non-empty "db_password" + And the site "bh-site-type-php-10.test" should have an empty "db_root_password" + And the global database "bh_site_type_php_10_test" should exist + + Scenario: A global-db php site connects to its database and has no db container + Given a php site "bh-site-type-php-10.test" created with "--with-db" exists + Then the php service of site "bh-site-type-php-10.test" should be able to connect to the site database + And the "db" service of site "bh-site-type-php-10.test" should not exist + And the site "bh-site-type-php-10.test" should have 3 running containers + And the site "bh-site-type-php-10.test" should have 8 docker volumes + And the "php" service of site "bh-site-type-php-10.test" should be attached to the docker network "ee-global-backend-network" + And the file "/opt/easyengine/sites/bh-site-type-php-10.test/docker-compose.yml" should not contain "easyengine/mariadb" + And the file "/opt/easyengine/sites/bh-site-type-php-10.test/.env" should not contain "MYSQL_ROOT_PASSWORD" + And a GET request to "http://bh-site-type-php-10.test/" should return 200 + + Scenario: --dbname, --dbuser and --dbpass are used for the global db + When I run "bin/ee site create bh-site-type-php-11.test --type=php --with-db --dbname=bh_stp_db11 --dbuser=bh_stp_user11 --dbpass=Bh-Stp-Pass-11" + Then the command should succeed + And the site "bh-site-type-php-11.test" should have the field "db_name" set to "bh_stp_db11" + And the site "bh-site-type-php-11.test" should have the field "db_user" set to "bh_stp_user11" + And the site "bh-site-type-php-11.test" should have the field "db_password" set to "Bh-Stp-Pass-11" + And the global database "bh_stp_db11" should exist + And the global database user "bh_stp_user11" should exist + And the php service of site "bh-site-type-php-11.test" should be able to connect to the site database + + Scenario: --dbhost=global-db is the same as the default global db + When I run "bin/ee site create bh-site-type-php-15.test --type=php --with-db --dbhost=global-db" + Then the command should succeed + And the site "bh-site-type-php-15.test" should have the field "db_host" set to "global-db" + And the site "bh-site-type-php-15.test" should have the field "db_name" set to "bh_site_type_php_15_test" + And the global database "bh_site_type_php_15_test" should exist + And the php service of site "bh-site-type-php-15.test" should be able to connect to the site database + + Scenario: --local-db runs a MariaDB container for the site + When I run "bin/ee site create bh-site-type-php-12.test --type=php --with-db --local-db" + Then the command should succeed + And STDOUT should match: + """ + /\|\s*DB Host\s*\|\s*db\s*\|/ + """ + And STDOUT should contain "DB Root Password" + And the site "bh-site-type-php-12.test" should have the field "db_host" set to "db" + And the site "bh-site-type-php-12.test" should have the field "db_name" set to "bh_site_type_php_12_test" + And the site "bh-site-type-php-12.test" should have a non-empty "db_root_password" + And the site "bh-site-type-php-12.test" should have a non-empty "db_password" + And the global database "bh_site_type_php_12_test" should not exist + + Scenario: A local-db php site has its db service, env and volumes + Given a php site "bh-site-type-php-12.test" created with "--with-db --local-db" exists + Then the "db" service of site "bh-site-type-php-12.test" should be running + And the "db" service of site "bh-site-type-php-12.test" should use the pinned "easyengine/mariadb" image + And the "db" service of site "bh-site-type-php-12.test" should have the restart policy "always" + And the site "bh-site-type-php-12.test" should have 4 running containers + And the site "bh-site-type-php-12.test" should have 11 docker volumes + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_ROOT_PASSWORD=" + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_DATABASE=bh_site_type_php_12_test" + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_USER=bh-site-type-php-12.test-" + And the file "/opt/easyengine/sites/bh-site-type-php-12.test/.env" should contain "MYSQL_PASSWORD=" + And the database "bh_site_type_php_12_test" should exist in the db service of site "bh-site-type-php-12.test" + And the php service of site "bh-site-type-php-12.test" should be able to connect to the site database + And the "php" service of site "bh-site-type-php-12.test" should not be attached to the docker network "ee-global-backend-network" + + # Current behaviour: --local-db only acts inside --with-db, so alone it is silently ignored. + Scenario: --local-db without --with-db creates no database at all + When I run "bin/ee site create bh-site-type-php-13.test --type=php --local-db" + Then the command should succeed + And the site "bh-site-type-php-13.test" should have the field "app_sub_type" set to "php" + And the site "bh-site-type-php-13.test" should have an empty "db_host" + And the "db" service of site "bh-site-type-php-13.test" should not exist + And the global database "bh_site_type_php_13_test" should not exist + + Scenario: A remote --dbhost with host:port and existing credentials is used as given + Given the global database "bh_stp_remote14" with user "bh_stp_remote14" identified by "Bh-Stp-Remote-14" exists + When I run "bin/ee site create bh-site-type-php-14.test --type=php --with-db --dbhost=global-db:3306 --dbname=bh_stp_remote14 --dbuser=bh_stp_remote14 --dbpass=Bh-Stp-Remote-14" + Then the command should succeed + And the site "bh-site-type-php-14.test" should have the field "db_host" set to "global-db" + And the site "bh-site-type-php-14.test" should have the field "db_port" set to "3306" + And the site "bh-site-type-php-14.test" should have the field "db_name" set to "bh_stp_remote14" + And the site "bh-site-type-php-14.test" should have the field "db_user" set to "bh_stp_remote14" + And the php service of site "bh-site-type-php-14.test" should be able to connect to the site database + + Scenario: A remote --dbhost needs --dbuser and --dbpass + When I run "bin/ee site create bh-site-type-php-16.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid" + Then the return code should be 1 + And STDERR should contain "`--dbuser` and `--dbpass` are required for remote db host." + And the site "bh-site-type-php-16.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-16.test" should not exist + When I run "bin/ee site create bh-site-type-php-16.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid --dbuser=bh_stp_u16" + Then the return code should be 1 + And STDERR should contain "`--dbuser` and `--dbpass` are required for remote db host." + + # Nothing probes a remote db today, with or without --skip-check, so this only proves the create and the port parsing; the skip itself becomes testable once remote credentials are checked. + Scenario: --skip-check creates the site without probing the remote db, and the port is parsed + When I run "bin/ee site create bh-site-type-php-18.test --type=php --with-db --dbhost=bh-stp-unreachable.invalid:3307 --dbuser=bh_stp_u18 --dbpass=bh-stp-p18 --skip-check" + Then the command should succeed + And STDOUT should not contain "Verifying connection to remote database" + And the site "bh-site-type-php-18.test" should have the field "db_host" set to "bh-stp-unreachable.invalid" + And the site "bh-site-type-php-18.test" should have the field "db_port" set to "3307" + And the site "bh-site-type-php-18.test" should have the field "db_user" set to "bh_stp_u18" + And the site "bh-site-type-php-18.test" should have an empty "db_root_password" + And the "db" service of site "bh-site-type-php-18.test" should not exist + + # Another site's --dbname is refused up front; the rollback only drops what the create itself made. + Scenario: Reusing another site's --dbname fails without dropping that database + Given a php site "bh-site-type-php-20.test" created with "--with-db --dbname=bh_stp_shared20 --dbuser=bh_stp_user20 --dbpass=Bh-Stp-Pass-20" exists + And the global database "bh_stp_shared20" contains the table "bh_marker" + When I run "bin/ee site create bh-site-type-php-21.test --type=php --with-db --dbname=bh_stp_shared20" + Then the command should fail + And STDERR should contain "Database `bh_stp_shared20` is already used by site bh-site-type-php-20.test on the global database." + And the directory "/opt/easyengine/sites/bh-site-type-php-21.test" should not exist + And the site "bh-site-type-php-21.test" should not exist + And the global database "bh_stp_shared20" should contain the table "bh_marker" + And the php service of site "bh-site-type-php-20.test" should be able to connect to the site database + + # Another site's --dbuser is refused up front, so its user and password are untouched. + Scenario: Reusing another site's --dbuser fails without dropping that user + Given a php site "bh-site-type-php-23.test" created with "--with-db --dbname=bh_stp_db23 --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Pass-23" exists + When I run "bin/ee site create bh-site-type-php-24.test --type=php --with-db --dbuser=bh_stp_user23 --dbpass=Bh-Stp-Other-24" + Then the command should fail + And STDERR should contain "Database user `bh_stp_user23` is already used by site bh-site-type-php-23.test on the global database." + And the global database "bh_site_type_php_24_test" should not exist + And the site "bh-site-type-php-24.test" should not exist + And the global database user "bh_stp_user23" should exist + And the php service of site "bh-site-type-php-23.test" should be able to connect to the site database + + # The default db name replaces "." and "-" with "_", so these two names collide; the pair also shares volume names, so the second create is refused before the database step. + Scenario: Two site names that map to the same default db name do not destroy each other's database + Given a php site "bh-site-type-php-22.x.test" created with "--with-db" exists + And the global database "bh_site_type_php_22_x_test" contains the table "bh_marker" + When I run "bin/ee site create bh-site-type-php-22-x.test --type=php --with-db" + Then the command should fail + And STDERR should contain "would share docker volumes (bhsitetypephp22xtest_*) with the existing site bh-site-type-php-22.x.test." + And the site "bh-site-type-php-22-x.test" should not exist + And the global database "bh_site_type_php_22_x_test" should contain the table "bh_marker" + And the php service of site "bh-site-type-php-22.x.test" should be able to connect to the site database + + # A default db name that is already taken (here by a database no site owns) gets a numbered suffix. + Scenario: A taken default db name gets a numbered suffix and the existing database is kept + Given the global database "bh_site_type_php_28_test" with user "bh_stp_user28" identified by "Bh-Stp-Pass-28" exists + And the global database "bh_site_type_php_28_test" contains the table "bh_marker" + When I run "bin/ee site create bh-site-type-php-28.test --type=php --with-db" + Then the command should succeed + And STDOUT should contain "Database `bh_site_type_php_28_test` already exists, using `bh_site_type_php_28_test_2` instead." + And the site "bh-site-type-php-28.test" should have the field "db_name" set to "bh_site_type_php_28_test_2" + And the global database "bh_site_type_php_28_test" should contain the table "bh_marker" + And the php service of site "bh-site-type-php-28.test" should be able to connect to the site database + When I run "bin/ee site delete bh-site-type-php-28.test --yes" + Then the command should succeed + And the global database "bh_site_type_php_28_test_2" should not exist + And the global database "bh_site_type_php_28_test" should contain the table "bh_marker" + + Scenario: Deleting a global-db site drops its database and user + Given a php site "bh-site-type-php-25.test" created with "--with-db --dbuser=bh_stp_user25 --dbpass=Bh-Stp-Pass-25" exists + And the global database "bh_site_type_php_25_test" should exist + When I run "bin/ee site delete bh-site-type-php-25.test --yes" + Then the command should succeed + And the site "bh-site-type-php-25.test" should not exist + And the global database "bh_site_type_php_25_test" should not exist + And the global database user "bh_stp_user25" should not exist + + Scenario: Deleting a local-db site removes the db container and its volumes + Given a php site "bh-site-type-php-26.test" created with "--with-db --local-db" exists + When I run "bin/ee site delete bh-site-type-php-26.test --yes" + Then the command should succeed + And the "db" service of site "bh-site-type-php-26.test" should not exist + And the site "bh-site-type-php-26.test" should have 0 docker volumes + And the directory "/opt/easyengine/sites/bh-site-type-php-26.test" should not exist diff --git a/features/labels.feature b/features/labels.feature index 3737994..2f8a280 100644 --- a/features/labels.feature +++ b/features/labels.feature @@ -1,9 +1,12 @@ Feature: Container Labels - - Scenario: All easyengine containers are tagged - Given I run "bin/ee site create labels.test --type=php" - Then There should be 5 containers with labels + # The old assertion was inverted and passed for any count but the expected one. + Scenario: All easyengine containers, volumes and the network of a php site are tagged + Given a php site "bh-site-type-php-90.test" exists + Then There should be 3 containers with labels """ - io.easyengine.site=labels.test + io.easyengine.site=bh-site-type-php-90.test """ + And the site "bh-site-type-php-90.test" should have 8 docker volumes + And the docker network "bh-site-type-php-90.test" should have the label "io.easyengine.site=bh-site-type-php-90.test" + And the docker network "bh-site-type-php-90.test" should have the label "org.label-schema.vendor=EasyEngine" diff --git a/features/lifecycle.feature b/features/lifecycle.feature new file mode 100644 index 0000000..d2c3231 --- /dev/null +++ b/features/lifecycle.feature @@ -0,0 +1,245 @@ +Feature: info, list, enable, disable, refresh, restart, reload, proxy cache and delete of php sites + + Scenario: site info shows the php site details + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site info bh-site-type-php-100.test" + Then the command should succeed + And STDOUT should contain "| Site | http://bh-site-type-php-100.test" + And STDOUT should contain "| Site Root | /opt/easyengine/sites/bh-site-type-php-100.test" + And STDOUT should contain "| Alias Domains | None" + And STDOUT should contain "| E-Mail | admin@bh-site-type-php-100.test" + And STDOUT should contain "| SSL | Not Enabled" + And STDOUT should contain "| Cache | None" + And STDOUT should not contain "SSL Wildcard" + And STDOUT should not contain "DB Name" + + Scenario: site info --format=json dumps the site row + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site info bh-site-type-php-100.test --format=json" + Then the command should succeed + And STDOUT should be valid json + And the JSON in STDOUT should have "site_url" set to "bh-site-type-php-100.test" + And the JSON in STDOUT should have "site_type" set to "php" + And the JSON in STDOUT should have "app_sub_type" set to "php" + And the JSON in STDOUT should have "php_version" set to "8.4" + And the JSON in STDOUT should have "site_enabled" set to "1" + And the JSON in STDOUT should have "site_container_fs_path" set to "/var/www/htdocs" + + Scenario: site info of a php site with a database and cache lists them + Given a php site "bh-site-type-php-101.test" created with "--with-db --cache --dbname=bh_stp_db101 --dbuser=bh_stp_user101 --dbpass=Bh-Stp-Pass-101" exists + When I run "bin/ee site info bh-site-type-php-101.test" + Then the command should succeed + And STDOUT should match: + """ + /\|\s*DB Host\s*\|\s*global-db\s*\|.*\|\s*DB Name\s*\|\s*bh_stp_db101\s*\|.*\|\s*DB User\s*\|\s*bh_stp_user101\s*\|.*\|\s*DB Password\s*\|\s*Bh-Stp-Pass-101\s*\|/s + """ + And STDOUT should contain "| Cache | Enabled" + When I run "bin/ee site info bh-site-type-php-101.test --format=json" + Then the JSON in STDOUT should have "db_name" set to "bh_stp_db101" + And the JSON in STDOUT should have "cache_host" set to "global-redis" + + # The site root, not app/htdocs: app is a symlink into the docker volume, and get_site_name() reads the physical `pwd`. + Scenario: site info works from inside the site directory + Given a php site "bh-site-type-php-100.test" exists + When I change to the directory "/opt/easyengine/sites/bh-site-type-php-100.test" and run "bin/ee site info" + Then the command should succeed + And STDOUT should contain "| Site | http://bh-site-type-php-100.test" + + Scenario: site info of an unknown site fails + When I run "bin/ee site info bh-site-type-php-197.test" + Then the return code should be 1 + And STDERR should contain "Could not find the site you wish to run site info command on." + + Scenario: site list shows php sites with their status + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site list --format=json" + Then the command should succeed + And the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-100.test" + And the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-100.test" and "status" set to "enabled" + When I run "bin/ee site list --format=text" + Then STDOUT should contain "bh-site-type-php-100.test" + + Scenario: site list --enabled and --disabled filter php sites + Given a php site "bh-site-type-php-100.test" exists + And a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site list --enabled --format=json" + Then the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-100.test" + And the JSON list in STDOUT should not contain an item with "site" set to "bh-site-type-php-102.test" + When I run "bin/ee site list --disabled --format=json" + Then the JSON list in STDOUT should contain an item with "site" set to "bh-site-type-php-102.test" + And the JSON list in STDOUT should not contain an item with "site" set to "bh-site-type-php-100.test" + + Scenario: Disable a php site + Given a php site "bh-site-type-php-102.test" exists + And I run "bin/ee site enable bh-site-type-php-102.test --force" + When I run "bin/ee site disable bh-site-type-php-102.test" + Then the command should succeed + And STDOUT should contain "Success: Site bh-site-type-php-102.test disabled." + And the site "bh-site-type-php-102.test" should be disabled + And the "php" service of site "bh-site-type-php-102.test" should not exist + And the "nginx" service of site "bh-site-type-php-102.test" should not exist + And the site "bh-site-type-php-102.test" should have 8 docker volumes + And the file "/opt/easyengine/sites/bh-site-type-php-102.test/app/htdocs/index.php" should exist + And I wait until a GET request to "http://bh-site-type-php-102.test/" no longer returns 200 within 60 seconds + + Scenario: Disabling a disabled php site is harmless + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site disable bh-site-type-php-102.test" + Then the command should succeed + And the site "bh-site-type-php-102.test" should be disabled + + Scenario: site info still works for a disabled php site + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site info bh-site-type-php-102.test" + Then the command should succeed + And STDOUT should contain "| Site | http://bh-site-type-php-102.test" + + Scenario: Enable a disabled php site + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site enable bh-site-type-php-102.test" + Then the command should succeed + And STDOUT should contain "Success: Site bh-site-type-php-102.test enabled." + And STDOUT should contain "Success: Post enable configurations complete." + And the site "bh-site-type-php-102.test" should be enabled + And the "php" service of site "bh-site-type-php-102.test" should be running + And the "nginx" service of site "bh-site-type-php-102.test" should be running + And the "postfix" service of site "bh-site-type-php-102.test" should be running + And the "mailhog" service of site "bh-site-type-php-102.test" should not be running + And I wait until a GET request to "http://bh-site-type-php-102.test/" returns 200 within 60 seconds + + Scenario: Enabling an enabled php site is refused unless forced + Given a php site "bh-site-type-php-102.test" exists + And I run "bin/ee site enable bh-site-type-php-102.test --force" + When I run "bin/ee site enable bh-site-type-php-102.test" + Then the return code should be 1 + And STDERR should contain "Error: bh-site-type-php-102.test is already enabled!" + When I run "bin/ee site enable bh-site-type-php-102.test --force" + Then the command should succeed + And STDOUT should contain "Success: Site bh-site-type-php-102.test enabled." + + Scenario: site refresh regenerates the docker-compose file of a php site + Given a php site "bh-site-type-php-106.test" exists + And I append "# bh-compose-marker" to the file "/opt/easyengine/sites/bh-site-type-php-106.test/docker-compose.yml" + When I run "bin/ee site refresh bh-site-type-php-106.test" + Then the command should succeed + And the file "/opt/easyengine/sites/bh-site-type-php-106.test/docker-compose.yml" should not contain "# bh-compose-marker" + And the docker-compose file of site "bh-site-type-php-106.test" should use the pinned "easyengine/php8.4" image + And I wait until a GET request to "http://bh-site-type-php-106.test/" returns 200 within 60 seconds + + Scenario: site restart restarts nginx, php and the local db, not postfix + Given a php site "bh-site-type-php-103.test" created with "--with-db --local-db" exists + And I note the start time of the "nginx" service of site "bh-site-type-php-103.test" + And I note the start time of the "php" service of site "bh-site-type-php-103.test" + And I note the start time of the "db" service of site "bh-site-type-php-103.test" + And I note the start time of the "postfix" service of site "bh-site-type-php-103.test" + When I run "bin/ee site restart bh-site-type-php-103.test" + Then the command should succeed + And STDOUT should contain "Restarting nginx" + And STDOUT should contain "Restarting php" + And STDOUT should contain "Restarting db" + And the "nginx" service of site "bh-site-type-php-103.test" should have been restarted + And the "php" service of site "bh-site-type-php-103.test" should have been restarted + And the "db" service of site "bh-site-type-php-103.test" should have been restarted + And the "postfix" service of site "bh-site-type-php-103.test" should not have been restarted + And I wait until a GET request to "http://bh-site-type-php-103.test/" returns 200 within 60 seconds + + Scenario: site restart --php restarts only php + Given a php site "bh-site-type-php-103.test" created with "--with-db --local-db" exists + And I note the start time of the "nginx" service of site "bh-site-type-php-103.test" + And I note the start time of the "php" service of site "bh-site-type-php-103.test" + When I run "bin/ee site restart bh-site-type-php-103.test --php" + Then the command should succeed + And STDOUT should contain "Restarting php" + And STDOUT should not contain "Restarting nginx" + And the "php" service of site "bh-site-type-php-103.test" should have been restarted + And the "nginx" service of site "bh-site-type-php-103.test" should not have been restarted + + Scenario: site restart --db restarts the local db + Given a php site "bh-site-type-php-103.test" created with "--with-db --local-db" exists + And I note the start time of the "db" service of site "bh-site-type-php-103.test" + When I run "bin/ee site restart bh-site-type-php-103.test --db" + Then the command should succeed + And STDOUT should contain "Restarting db" + And the "db" service of site "bh-site-type-php-103.test" should have been restarted + + Scenario: site reload reloads nginx and php-fpm without restarting containers + Given a php site "bh-site-type-php-100.test" exists + And I note the start time of the "nginx" service of site "bh-site-type-php-100.test" + And I note the start time of the "php" service of site "bh-site-type-php-100.test" + When I run "bin/ee site reload bh-site-type-php-100.test" + Then the command should succeed + And STDOUT should contain "Reloading nginx" + And STDOUT should contain "Reloading php" + And the "nginx" service of site "bh-site-type-php-100.test" should not have been restarted + And the "php" service of site "bh-site-type-php-100.test" should not have been restarted + And I wait until a GET request to "http://bh-site-type-php-100.test/" returns 200 within 30 seconds + + Scenario: site reload --php and --nginx reload a single service + Given a php site "bh-site-type-php-100.test" exists + When I run "bin/ee site reload bh-site-type-php-100.test --php" + Then the command should succeed + And STDOUT should contain "Reloading php" + And STDOUT should not contain "Reloading nginx" + When I run "bin/ee site reload bh-site-type-php-100.test --nginx" + Then the command should succeed + And STDOUT should contain "Reloading nginx" + And STDOUT should not contain "Reloading php" + + Scenario: site reload of a disabled php site is refused + Given a php site "bh-site-type-php-102.test" exists + And the site "bh-site-type-php-102.test" is disabled + When I run "bin/ee site reload bh-site-type-php-102.test" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-102.test is not enabled. Use `ee site enable bh-site-type-php-102.test` to enable it." + + # update --proxy-cache restarts the global nginx-proxy container, so it briefly affects every site on the host. + @destructive + Scenario: Turn the proxy cache of a php site with an alias domain on and off + Given a php site "bh-site-type-php-104.test" created with "--alias-domains=bh-site-type-php-104-a.test" exists + When I run "bin/ee site update bh-site-type-php-104.test --proxy-cache=on" + Then the command should succeed + And STDOUT should contain "Success: Proxy cache update on site bh-site-type-php-104.test completed." + And the site "bh-site-type-php-104.test" should have the field "proxy_cache" set to "on" + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-104.test.conf" should exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104.test_location" should exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104-a.test_location" should exist + And I wait until a GET request to "http://bh-site-type-php-104.test/" returns 200 within 90 seconds + When I run "bin/ee site update bh-site-type-php-104.test --proxy-cache=on" + Then the return code should be 1 + And STDERR should contain "Site bh-site-type-php-104.test already has proxy cache: on" + When I run "bin/ee site update bh-site-type-php-104.test --proxy-cache=off" + Then the command should succeed + And the site "bh-site-type-php-104.test" should have the field "proxy_cache" set to "off" + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-104.test.conf" should not exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104.test_location" should not exist + And the file "/opt/easyengine/services/nginx-proxy/vhost.d/bh-site-type-php-104-a.test_location" should not exist + And I wait until a GET request to "http://bh-site-type-php-104.test/" returns 200 within 90 seconds + And the docker container "services_global-nginx-proxy_1" should be running + + Scenario: Delete a php site and everything it owns + Given a php site "bh-site-type-php-105.test" exists + When I run "bin/ee site delete bh-site-type-php-105.test --yes" + Then the command should succeed + And STDOUT should contain "Site bh-site-type-php-105.test deleted." + And STDERR should be empty + And The 'bh-site-type-php-105.test' db entry should be removed + And The 'bh-site-type-php-105.test' webroot should be removed + And Following containers of site 'bh-site-type-php-105.test' should be removed: + | container | + | nginx | + | php | + | postfix | + | mailhog | + And the site "bh-site-type-php-105.test" should have 0 docker volumes + And the docker network "bh-site-type-php-105.test" should not exist + And the hosts file should not have an entry for "bh-site-type-php-105.test" + + Scenario: Deleting a php site twice fails the second time + Given the site "bh-site-type-php-105.test" does not exist + When I run "bin/ee site delete bh-site-type-php-105.test --yes" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-105.test does not exist." diff --git a/features/php-version.feature b/features/php-version.feature new file mode 100644 index 0000000..0457c33 --- /dev/null +++ b/features/php-version.feature @@ -0,0 +1,91 @@ +Feature: PHP versions of php sites (create --php, update --php) + + @slow + Scenario Outline: Create a php site on every supported PHP version + When I run "bin/ee site create --type=php --php=" + Then the command should succeed + And the site "" should have the field "php_version" set to "" + And the "php" service of site "" should use the pinned "" image + And the php service of site "" should run PHP "" + And a GET request to "http:///" should return 200 + And the response should contain "Congratulations!" + And the file "/opt/easyengine/sites//config/php/php/conf.d/custom.ini" should contain "" + + # "latest" maps to the php8.4 image (Site_PHP_Docker), not to the newest supported version. + Examples: + | site | version | image | runs | sendmail | + | bh-site-type-php-40.test | 5.6 | easyengine/php5.6 | 5.6 | sendmail_path = /usr/sbin/sendmail -t -i -f no-reply@example.com | + | bh-site-type-php-41.test | 7.0 | easyengine/php7.0 | 7.0 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-42.test | 7.2 | easyengine/php7.2 | 7.2 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-43.test | 7.3 | easyengine/php7.3 | 7.3 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-44.test | 7.4 | easyengine/php7.4 | 7.4 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-45.test | 8.0 | easyengine/php8.0 | 8.0 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-46.test | 8.1 | easyengine/php8.1 | 8.1 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-47.test | 8.2 | easyengine/php8.2 | 8.2 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-48.test | 8.3 | easyengine/php8.3 | 8.3 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-49.test | 8.4 | easyengine/php8.4 | 8.4 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-50.test | 8.5 | easyengine/php8.5 | 8.5 | sendmail_path = /usr/bin/msmtp -t | + | bh-site-type-php-51.test | latest | easyengine/php8.4 | 8.4 | sendmail_path = /usr/bin/msmtp -t | + + Scenario: Update the PHP version of a php site and keep custom php settings + Given a php site "bh-site-type-php-52.test" exists + And I append "; bh-custom-ini-marker" to the file "/opt/easyengine/sites/bh-site-type-php-52.test/config/php/php/conf.d/custom.ini" + When I run "bin/ee site update bh-site-type-php-52.test --php=8.3" + Then the command should succeed + And STDOUT should contain "Starting php version update for: bh-site-type-php-52.test" + And STDOUT should contain "Success: Updated site bh-site-type-php-52.test to PHP version: 8.3" + And the site "bh-site-type-php-52.test" should have the field "php_version" set to "8.3" + And the docker-compose file of site "bh-site-type-php-52.test" should use the pinned "easyengine/php8.3" image + And the "php" service of site "bh-site-type-php-52.test" should use the pinned "easyengine/php8.3" image + And the php service of site "bh-site-type-php-52.test" should run PHP "8.3" + And the directory "/opt/easyengine/sites/bh-site-type-php-52.test/.backup/config/php-8.4" should exist + And the file "/opt/easyengine/sites/bh-site-type-php-52.test/config/php/php/conf.d/custom.ini" should contain "; bh-custom-ini-marker" + And I wait until a GET request to "http://bh-site-type-php-52.test/" returns 200 within 60 seconds + + Scenario: Updating to the current PHP version is refused + Given a php site "bh-site-type-php-49.test" created with "--php=8.4" exists + When I run "bin/ee site update bh-site-type-php-49.test --php=8.4" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-49.test is already at PHP version: 8.4" + + Scenario Outline: An unsupported PHP version is refused by site update + Given a php site "bh-site-type-php-49.test" created with "--php=8.4" exists + When I run "bin/ee site update bh-site-type-php-49.test --php=" + Then the command should fail + And STDERR should contain "Invalid value specified for 'php'" + And the site "bh-site-type-php-49.test" should have the field "php_version" set to "8.4" + + Examples: + | version | + | 7.1 | + | latest | + | 9.9 | + + Scenario: Updating from PHP 5.6 switches sendmail to msmtp + Given a php site "bh-site-type-php-53.test" created with "--php=5.6" exists + When I run "bin/ee site update bh-site-type-php-53.test --php=8.4" + Then the command should succeed + And the php service of site "bh-site-type-php-53.test" should run PHP "8.4" + And the file "/opt/easyengine/sites/bh-site-type-php-53.test/config/php/php/conf.d/custom.ini" should contain "sendmail_path = /usr/bin/msmtp -t" + And I wait until a GET request to "http://bh-site-type-php-53.test/" returns 200 within 60 seconds + + Scenario: Updating to PHP 5.6 switches msmtp to sendmail + Given a php site "bh-site-type-php-54.test" exists + When I run "bin/ee site update bh-site-type-php-54.test --php=5.6" + Then the command should succeed + And the php service of site "bh-site-type-php-54.test" should run PHP "5.6" + And the file "/opt/easyengine/sites/bh-site-type-php-54.test/config/php/php/conf.d/custom.ini" should contain "sendmail_path = /usr/sbin/sendmail -t -i -f no-reply@example.com" + And I wait until a GET request to "http://bh-site-type-php-54.test/" returns 200 within 60 seconds + + Scenario: The PHP version of a disabled site can not be updated + Given a php site "bh-site-type-php-55.test" exists + And the site "bh-site-type-php-55.test" is disabled + When I run "bin/ee site update bh-site-type-php-55.test --php=8.3" + Then the return code should be 1 + And STDERR should contain "Error: Site bh-site-type-php-55.test is not enabled. Use `ee site enable bh-site-type-php-55.test` to enable it." + And the site "bh-site-type-php-55.test" should have the field "php_version" set to "8.4" + + Scenario: Updating a site that does not exist fails + When I run "bin/ee site update bh-site-type-php-198.test --php=8.3" + Then the return code should be 1 + And STDERR should contain "Could not find the site you wish to run site update command on." diff --git a/features/public-dir.feature b/features/public-dir.feature new file mode 100644 index 0000000..fb8141a --- /dev/null +++ b/features/public-dir.feature @@ -0,0 +1,49 @@ +Feature: Custom document root of php sites (--public-dir) + + Scenario: --public-dir serves the site from a sub directory of htdocs + When I run "bin/ee site create bh-site-type-php-60.test --type=php --public-dir=public" + Then the command should succeed + And the site "bh-site-type-php-60.test" should have the field "site_container_fs_path" set to "/var/www/htdocs/public" + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/config/nginx/conf.d/main.conf" should contain "root /var/www/htdocs/public;" + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/public/index.php" should contain "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/public" + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/index.php" should not exist + And the file "/opt/easyengine/sites/bh-site-type-php-60.test/app/htdocs/public/check.html" should not exist + And a GET request to "http://bh-site-type-php-60.test/" should return 200 + And the response should contain "Congratulations!" + + Scenario: Files outside the public directory are not served + Given a php site "bh-site-type-php-60.test" created with "--public-dir=public" exists + When I write " from the host, so the www name must resolve before the site is created. Scenario: no_www-no_ssl redirection works properly - When I run '/bin/bash -c 'echo "127.0.0.1 www.example.test" >> /etc/hosts'' - And I run 'bin/ee site create example.test --type=php' - Then After delay of 5 seconds - And Request on 'localhost' with header 'Host: www.example.test' should contain following headers: - | header | - | HTTP/1.1 301 Moved Permanently | - | Location: http://example.test/ | + Given the hosts file maps "www.bh-site-type-php-91.test" to "127.0.0.1" + When I run "bin/ee site create bh-site-type-php-91.test --type=php" + Then the command should succeed + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-91.test-redirect.conf" should exist + And I wait until a GET request to "http://www.bh-site-type-php-91.test/" returns 301 within 30 seconds + And Request on 'localhost' with header 'Host: www.bh-site-type-php-91.test' should contain following headers: + | header | + | HTTP/1.1 301 Moved Permanently | + | Location: http://bh-site-type-php-91.test/ | Scenario: www-no_ssl redirection works properly - When I run '/bin/bash -c 'echo "127.0.0.1 example1.test" >> /etc/hosts'' - And I run 'bin/ee site create www.example1.test --type=php' - Then After delay of 5 seconds - And Request on 'localhost' with header 'Host: example1.test' should contain following headers: - | header | - | HTTP/1.1 301 Moved Permanently | - | Location: http://www.example1.test/ | + Given the hosts file maps "bh-site-type-php-92.test" to "127.0.0.1" + When I run "bin/ee site create www.bh-site-type-php-92.test --type=php" + Then the command should succeed + And I wait until a GET request to "http://bh-site-type-php-92.test/" returns 301 within 30 seconds + And Request on 'localhost' with header 'Host: bh-site-type-php-92.test' should contain following headers: + | header | + | HTTP/1.1 301 Moved Permanently | + | Location: http://www.bh-site-type-php-92.test/ | + + Scenario: Without a resolvable www name no redirect is configured + When I run "bin/ee site create bh-site-type-php-93.test --type=php" + Then the command should succeed + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-93.test-redirect.conf" should not exist + + Scenario: Deleting a site removes its redirect config + Given the hosts file maps "www.bh-site-type-php-91.test" to "127.0.0.1" + And a php site "bh-site-type-php-91.test" exists + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-91.test-redirect.conf" should exist + When I run "bin/ee site delete bh-site-type-php-91.test --yes" + Then the command should succeed + And the file "/opt/easyengine/services/nginx-proxy/conf.d/bh-site-type-php-91.test-redirect.conf" should not exist diff --git a/features/site.feature b/features/site.feature index f442d41..2eba0fa 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1,4 +1,4 @@ -Feature: Site Command +Feature: Site command presence and usage errors Scenario: ee executable is command working correctly Given 'bin/ee' is installed @@ -9,6 +9,7 @@ Feature: Site Command ee """ + Scenario: Check site command is present When I run 'bin/ee site' Then STDOUT should return something like @@ -23,78 +24,74 @@ Feature: Site Command usage: ee site create [--ssl] [--ssl-key=] [--ssl-crt=] [--wildcard] [--type=] [--alias-domains=] [--skip-status-check] [--public-dir] """ - Scenario: Create php site successfully - When I run 'bin/ee site create php.test --type=php' - Then After delay of 5 seconds - And The site 'php.test' should have webroot - And The site 'php.test' should have index file - And Request on 'php.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: List the sites - When I run 'bin/ee site list --format=text' - Then STDOUT should return exactly - """ - php.test - """ + Scenario: php site create without a site name prints the php usage + When I run "bin/ee site create --type=php" + Then the return code should be 1 + # The synopsis has a stray double space before [--with-db] (docblock indentation), so match the two halves. + And STDOUT should contain "usage: ee site create [--cache] [--admin-email=]" + And STDOUT should contain "[--with-db] [--local-db] [--php=] [--alias-domains=]" + + Scenario: Help for php site create lists every php option + When I run "bin/ee help site create --type=php" + Then the command should succeed + And STDOUT should contain "Runs the standard PHP Site installation." + And STDOUT should contain "[--cache]" + And STDOUT should contain "[--admin-email=]" + And STDOUT should contain "[--with-db]" + And STDOUT should contain "[--local-db]" + And STDOUT should contain "[--php=]" + And STDOUT should contain "[--alias-domains=]" + And STDOUT should contain "[--dbname=]" + And STDOUT should contain "[--dbuser=]" + And STDOUT should contain "[--dbpass=]" + And STDOUT should contain "[--dbhost=]" + And STDOUT should contain "[--with-local-redis]" + And STDOUT should contain "[--skip-check]" + And STDOUT should contain "[--skip-status-check]" + And STDOUT should contain "[--ssl]" + And STDOUT should contain "[--ssl-key=]" + And STDOUT should contain "[--ssl-crt=]" + And STDOUT should contain "[--wildcard]" + And STDOUT should contain "[--force]" + And STDOUT should contain "[--public-dir]" + And STDOUT should contain "default: 8.4" + + Scenario: Help for php site info, restart and reload shows the php variants + When I run "bin/ee help site restart --type=php" + Then the command should succeed + And STDOUT should contain "[--php]" + And STDOUT should contain "[--db]" + When I run "bin/ee help site reload --type=php" + Then the command should succeed + And STDOUT should contain "[--php]" + And STDOUT should not contain "[--db]" + When I run "bin/ee help site info --type=php" + Then the command should succeed + And STDOUT should contain "[--format=]" Scenario: Check site disable sub command is present When I run 'bin/ee site disable' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site disable command on. - Either pass it as an argument: `ee site disable ` - or run `ee site disable` from inside the site folder. - """ + Then STDERR should contain "Error: Could not find the site you wish to run site disable command on." + And STDERR should contain "Either pass it as an argument: `ee site disable `" + And STDERR should contain "or run `ee site disable` from inside the site folder." Scenario: Check site enable sub command is present When I run 'bin/ee site enable' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site enable command on. - Either pass it as an argument: `ee site enable ` - or run `ee site enable` from inside the site folder. - """ + Then STDERR should contain "Error: Could not find the site you wish to run site enable command on." + And STDERR should contain "Either pass it as an argument: `ee site enable `" + And STDERR should contain "or run `ee site enable` from inside the site folder." Scenario: Check site reload sub command is present When I run 'bin/ee site reload' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site reload command on. - Either pass it as an argument: `ee site reload ` - or run `ee site reload` from inside the site folder. - """ + Then STDERR should contain "Error: Could not find the site you wish to run site reload command on." + And STDERR should contain "Either pass it as an argument: `ee site reload `" + And STDERR should contain "or run `ee site reload` from inside the site folder." Scenario: Check site info sub command is present When I run 'bin/ee site info' - Then STDERR should return something like - """ - Error: Could not find the site you wish to run site info command on. - Either pass it as an argument: `ee site info ` - or run `ee site info` from inside the site folder. - """ - - Scenario: Details of the site uing site info command - When I run 'bin/ee site info php.test' - Then STDOUT should return something like - """ - | Site | http://php.test - """ - - Scenario: Reload site services - When I run 'bin/ee site reload php.test' - Then STDOUT should return something like - """ - Reloading nginx - """ - - Scenario: Reload site nginx services - When I run 'bin/ee site reload php.test --nginx' - Then STDOUT should return something like - """ - Reloading nginx - """ + Then STDERR should contain "Error: Could not find the site you wish to run site info command on." + And STDERR should contain "Either pass it as an argument: `ee site info `" + And STDERR should contain "or run `ee site info` from inside the site folder." Scenario: Check site delete sub command is present When I run 'bin/ee site delete' @@ -103,54 +100,8 @@ Feature: Site Command usage: ee site delete [--yes] """ - Scenario: Create PHP site with local DB and global redis - When I run 'bin/ee site create php-local-db.test --cache --type=php --local-db --with-db' - Then After delay of 5 seconds - And The site 'php-local-db.test' should have webroot - And The site 'php-local-db.test' should have index file - And Request on 'php-local-db.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - And Check global redis cache for 'php-local-db.test' - And Check local mysql database connection for 'php-local-db.test' - - Scenario: Create PHP site with local redis and global db - When I run 'bin/ee site create php-local-redis.test --cache --type=php --with-db --with-local-redis' - Then After delay of 5 seconds - And The site 'php-local-redis.test' should have webroot - And The site 'php-local-redis.test' should have index file - And Request on 'php-local-redis.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - And Check local redis cache for 'php-local-redis.test' - And Check global mysql database connection for 'php-local-redis.test' - - Scenario: Create PHP site with local DB and local redis - When I run 'bin/ee site create php-local-db-redis.test --cache --type=php --local-db --with-db --with-local-redis' - Then After delay of 5 seconds - And The site 'php-local-db-redis.test' should have webroot - And The site 'php-local-db-redis.test' should have index file - And Request on 'php-local-db-redis.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - And Check local redis cache for 'php-local-db-redis.test' - And Check local mysql database connection for 'php-local-db-redis.test' - - Scenario: Delete the sites - When I run 'bin/ee site delete php.test --yes' - Then STDOUT should return something like - """ - Site php.test deleted. - """ - And STDERR should return exactly - """ - """ - And The 'php.test' db entry should be removed - And The 'php.test' webroot should be removed - And Following containers of site 'php.test' should be removed: - | container | - | nginx | - | php | - | db | - | redis | - | phpmyadmin | + Scenario: An unknown site type is rejected + When I run "bin/ee site create bh-site-type-php-199.test --type=bh-nope" + Then the command should fail + And STDERR should contain "'bh-nope' is not a registered site type" + And the site "bh-site-type-php-199.test" should not exist diff --git a/features/ssl.feature b/features/ssl.feature new file mode 100644 index 0000000..d84dcea --- /dev/null +++ b/features/ssl.feature @@ -0,0 +1,151 @@ +Feature: SSL on php sites (create --ssl=self|custom|inherit|le, update --ssl) + + Scenario: Create a php site with a self-signed certificate + When I run "bin/ee site create bh-site-type-php-80.test --type=php --ssl=self" + Then the command should succeed + And STDOUT should contain "| Site | https://bh-site-type-php-80.test" + And STDOUT should contain "| SSL | Enabled" + And STDOUT should contain "| SSL Wildcard | Yes" + And the site "bh-site-type-php-80.test" should have the field "site_ssl" set to "self" + And the site "bh-site-type-php-80.test" should have the field "site_ssl_wildcard" set to "1" + And the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-80.test.crt" should exist + And the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-80.test.key" should exist + And the hosts file should have an entry for "bh-site-type-php-80.test" + + Scenario: A self-signed php site is served over HTTPS and redirects HTTP + Given a php site "bh-site-type-php-80.test" created with "--ssl=self" exists + Then a GET request to "https://bh-site-type-php-80.test/" should return 200 + And the response should contain "Congratulations!" + And a GET request to "http://bh-site-type-php-80.test/" should return 301 + And the response header "Location" should contain "https://bh-site-type-php-80.test/" + And the certificate served for "bh-site-type-php-80.test" should cover "bh-site-type-php-80.test" + And the certificate served for "bh-site-type-php-80.test" should cover "*.bh-site-type-php-80.test" + And the certificate served for "bh-site-type-php-80.test" should match the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-80.test.crt" + And the file "/opt/easyengine/sites/bh-site-type-php-80.test/docker-compose.yml" should not contain "HTTPS_METHOD=nohttps" + + Scenario: Create a php site with a custom certificate + Given a self-signed certificate for "bh-site-type-php-81.test" exists in "/var/tmp/bh-site-type-php/certs" + And the hosts file maps "bh-site-type-php-81.test" to "127.0.0.1" + When I run "bin/ee site create bh-site-type-php-81.test --type=php --ssl=custom --ssl-key=/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.key --ssl-crt=/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" + Then the command should succeed + And the site "bh-site-type-php-81.test" should have the field "site_ssl" set to "custom" + And the files "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" and "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-81.test.crt" should be identical + And the files "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.key" and "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-81.test.key" should be identical + And I wait until a GET request to "https://bh-site-type-php-81.test/" via "127.0.0.1" returns 200 within 60 seconds + And the certificate served for "bh-site-type-php-81.test" should match the file "/var/tmp/bh-site-type-php/certs/bh-site-type-php-81.test.crt" + + Scenario: --ssl=custom needs both --ssl-key and --ssl-crt + When I run "bin/ee site create bh-site-type-php-82.test --type=php --ssl=custom --ssl-crt=/var/tmp/bh-site-type-php/certs/none.crt" + Then the return code should be 1 + And STDERR should contain "Pass --ssl-key and --ssl-crt for custom SSL" + And the site "bh-site-type-php-82.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-82.test" should not exist + + Scenario: --ssl=custom with a missing certificate file fails + When I run "bin/ee site create bh-site-type-php-83.test --type=php --ssl=custom --ssl-key=/var/tmp/bh-site-type-php/certs/none.key --ssl-crt=/var/tmp/bh-site-type-php/certs/none.crt" + Then the return code should be 1 + And STDERR should contain "ssl-key OR ssl-crt path does not exist" + And the site "bh-site-type-php-83.test" should not exist + + Scenario: --ssl=inherit without a parent site fails and rolls back + When I run "bin/ee site create bh-site-type-php-84.test --type=php --ssl=inherit" + Then the return code should be 1 + And STDERR should contain "Unable to find existing site: test" + And the site "bh-site-type-php-84.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-84.test" should not exist + + Scenario: A subdomain site inherits the wildcard certificate of its parent + Given a php site "bh-site-type-php-85.test" created with "--ssl=self" exists + And the hosts file maps "sub.bh-site-type-php-85.test" to "127.0.0.1" + When I run "bin/ee site create sub.bh-site-type-php-85.test --type=php --ssl=inherit" + Then the command should succeed + And STDOUT should contain "Inherited certs from parent" + And the site "sub.bh-site-type-php-85.test" should have the field "site_ssl" set to "inherit" + And I wait until a GET request to "https://sub.bh-site-type-php-85.test/" via "127.0.0.1" returns 200 within 60 seconds + And the certificate served for "sub.bh-site-type-php-85.test" should cover "*.bh-site-type-php-85.test" + + Scenario: --ssl=inherit together with --wildcard is refused and rolled back + Given a php site "bh-site-type-php-85.test" created with "--ssl=self" exists + And the hosts file maps "sub2.bh-site-type-php-85.test" to "127.0.0.1" + When I run "bin/ee site create sub2.bh-site-type-php-85.test --type=php --ssl=inherit --wildcard" + Then the return code should be 1 + And STDERR should contain "Cannot use --wildcard with --ssl=inherit" + And the site "sub2.bh-site-type-php-85.test" should not exist + And the directory "/opt/easyengine/sites/sub2.bh-site-type-php-85.test" should not exist + And the site "sub2.bh-site-type-php-85.test" should have 0 docker volumes + And the "nginx" service of site "sub2.bh-site-type-php-85.test" should not exist + And the file "/opt/easyengine/services/nginx-proxy/conf.d/sub2.bh-site-type-php-85.test-redirect.conf" should not exist + + Scenario: --ssl with an unknown value fails and rolls back + When I run "bin/ee site create bh-site-type-php-87.test --type=php --ssl=bogus --skip-status-check" + Then the return code should be 1 + And STDERR should contain "Unrecognized value in --ssl flag: bogus" + And the site "bh-site-type-php-87.test" should not exist + And the directory "/opt/easyengine/sites/bh-site-type-php-87.test" should not exist + And the site "bh-site-type-php-87.test" should have 0 docker volumes + And the "nginx" service of site "bh-site-type-php-87.test" should not exist + And the docker network "bh-site-type-php-87.test" should not exist + + Scenario: Enable and disable self-signed SSL on an existing php site + Given a php site "bh-site-type-php-88.test" exists + When I run "bin/ee site update bh-site-type-php-88.test --ssl=self" + Then the command should succeed + And STDOUT should contain "Success: Enabled SSL for bh-site-type-php-88.test" + And the site "bh-site-type-php-88.test" should have the field "site_ssl" set to "self" + And I wait until a GET request to "https://bh-site-type-php-88.test/" returns 200 within 60 seconds + When I run "bin/ee site update bh-site-type-php-88.test --ssl=self" + Then the return code should be 1 + And STDERR should contain "Error: SSL is already enabled on bh-site-type-php-88.test" + When I run "bin/ee site update bh-site-type-php-88.test --ssl=off" + Then the command should succeed + And STDOUT should contain "Success: Disabled SSL for bh-site-type-php-88.test" + And the site "bh-site-type-php-88.test" should have an empty "site_ssl" + And the file "/opt/easyengine/sites/bh-site-type-php-88.test/docker-compose.yml" should contain "HTTPS_METHOD=nohttps" + And I wait until a GET request to "http://bh-site-type-php-88.test/" returns 200 within 60 seconds + When I run "bin/ee site update bh-site-type-php-88.test --ssl=off" + Then the return code should be 1 + And STDERR should contain "Error: SSL is already disabled on bh-site-type-php-88.test" + + # Site 80 is SSL-on with site_ssl_wildcard=1 (from create --ssl=self), the only state that reaches this check. + Scenario: --ssl=off with --wildcard is refused + Given a php site "bh-site-type-php-80.test" created with "--ssl=self" exists + When I run "bin/ee site update bh-site-type-php-80.test --ssl=off --wildcard" + Then the return code should be 1 + And STDERR should contain "You cannot use --wildcard flag with --ssl=off" + And the site "bh-site-type-php-80.test" should have the field "site_ssl" set to "self" + + # Self-signed certificates are always wildcard (site-command 5c34be8, v3.8.1), so the refusal is checked with LE; it fails before any ACME call. + # A site that never had SSL: one that had --ssl=self keeps site_ssl_wildcard=1 after --ssl=off, and would go on to request a wildcard certificate. + Scenario: Switching a normal SSL site to wildcard SSL is refused + Given a php site "bh-site-type-php-200.test" exists + When I run "bin/ee site update bh-site-type-php-200.test --ssl=le --wildcard" + Then the return code should be 1 + And STDERR should contain "Update from normal SSL to wildcard SSL is not supported yet." + + # Fixed by EasyEngine/site-command#505: update_ssl() refused --ssl=off for self-signed creates, which store site_ssl_wildcard=1. + Scenario: SSL of a site created with --ssl=self can be disabled + Given a php site "bh-site-type-php-89.test" created with "--ssl=self" exists + When I run "bin/ee site update bh-site-type-php-89.test --ssl=off" + Then the command should succeed + And the site "bh-site-type-php-89.test" should have an empty "site_ssl" + + @le + Scenario: Create and delete a php site with a Let's Encrypt certificate + Given the domain "bh-site-type-php-1.ee4-test.rt.gw" resolves publicly + And the ee config has a "le-mail" value + And the site "bh-site-type-php-1.ee4-test.rt.gw" does not exist + When I run "bin/ee site create bh-site-type-php-1.ee4-test.rt.gw --type=php --ssl=le" + Then the command should succeed + And the site "bh-site-type-php-1.ee4-test.rt.gw" should have the field "site_ssl" set to "le" + And the site "bh-site-type-php-1.ee4-test.rt.gw" should have the field "site_ssl_wildcard" set to "0" + And the hosts file should not have an entry for "bh-site-type-php-1.ee4-test.rt.gw" + And I wait until a GET request to "https://bh-site-type-php-1.ee4-test.rt.gw/" returns 200 within 60 seconds + And a GET request to "https://bh-site-type-php-1.ee4-test.rt.gw/" with certificate verification should return 200 + And the response should contain "Congratulations!" + And the certificate served for "bh-site-type-php-1.ee4-test.rt.gw" should be issued by the ACME CA + And a GET request to "http://bh-site-type-php-1.ee4-test.rt.gw/" should return 301 + When I run "bin/ee site delete bh-site-type-php-1.ee4-test.rt.gw --yes" + Then the command should succeed + And STDOUT should contain "Revoking certificate." + And the file "/opt/easyengine/services/nginx-proxy/certs/bh-site-type-php-1.ee4-test.rt.gw.crt" should not exist + And the site "bh-site-type-php-1.ee4-test.rt.gw" should not exist