diff --git a/.github/behat/pebble-setup.sh b/.github/behat/pebble-setup.sh new file mode 100755 index 0000000..5bc2011 --- /dev/null +++ b/.github/behat/pebble-setup.sh @@ -0,0 +1,402 @@ +#!/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 of wall-clock time, each attempt bounded by what is left. + local what=$1 end=$((SECONDS + 60)) left + shift + while ((SECONDS < end)); do + # timeout 0 would mean no limit. + left=$((end - SECONDS)) + timeout -k 5 $((left > 0 ? left : 1)) "$@" >/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..2e3fe1b --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,226 @@ +# 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 + # CI only, for `composer update` of the cloned core (develop and PRs): Composer blocks these versions for security advisories. Remove after the symfony/yaml and guzzlehttp/psr7 upgrade. + ADVISORY_IGNORE: >- + {"symfony/yaml": {"constraint": "4.4.*", "reason": "pinned by core and behat/behat 3.8.1", "on-audit": false}, + "guzzlehttp/psr7": {"constraint": "1.9.*", "reason": "required by acmephp/core 1.3.0", "on-audit": false}, + "guzzlehttp/guzzle": {"constraint": "7.8.*", "reason": "last guzzle 7 line that accepts guzzlehttp/psr7 1.x", "on-audit": false}} + +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 config --json --merge policy.advisories.ignore "$ADVISORY_IGNORE" + 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 7162601..0000000 --- a/.github/workflows/test_and_build.yml +++ /dev/null @@ -1,102 +0,0 @@ -on: - pull_request: - push: - branches: - - master - - develop - -name: Test 👨‍🔧 - -env: - # CI only, for `composer update` of the cloned core (develop and PRs): Composer blocks these versions for security advisories. Remove after the symfony/yaml and guzzlehttp/psr7 upgrade. - ADVISORY_IGNORE: >- - {"symfony/yaml": {"constraint": "4.4.*", "reason": "pinned by core and behat/behat 3.8.1", "on-audit": false}, - "guzzlehttp/psr7": {"constraint": "1.9.*", "reason": "required by acmephp/core 1.3.0", "on-audit": false}, - "guzzlehttp/guzzle": {"constraint": "7.8.*", "reason": "last guzzle 7 line that accepts guzzlehttp/psr7 1.x", "on-audit": false}} - -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 config --json --merge policy.advisories.ignore "$ADVISORY_IGNORE" - 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..600d56a --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,131 @@ +# site-type-wp Behat coverage + +Scope: `ee site create --type=wp` and everything `EE\Site\Type\WordPress` defines or overrides (`create`, `info`, `restart`, `reload`, `share`, `update_ssl`, `dump_docker_compose_yml`, rollback), plus the inherited site-command subcommands where their behaviour depends on the WordPress site type (enable/disable/refresh, delete, list, clean, update of php/proxy-cache/alias domains). Generic site-command features (backup/restore, clone/sync, ssl-verify/renew/info) belong to the site-command suite. + +Site names all start with `bh-site-type-wp-` (`*.test`, and `bh-site-type-wp-le1.ee4-test.rt.gw` for LE). `@BeforeFeature`/`@AfterFeature` delete only sites, webroots, containers, volumes, networks, global DB databases/users, redis keys, nginx-proxy certs and ACME state, `/etc/hosts` lines and `.backup` dirs that contain that prefix; `/etc/hosts` lines added by a scenario are removed after it. + +## Inventory + +Legend: file names are under `features/`. Scenarios that assert the correct behaviour of an open bug (they fail until it is fixed) are left out of the suite for now; their rows say "not covered yet" and the scenario comes back with the fix. + +### `ee site create --type=wp` arguments and flags + +| Item | Covered by | +|---|---| +| `` | site: Create a default WordPress site; missing name: site: Create without a site name prints the usage; `http://` prefix, trailing slash, upper case: install-options: Custom title and administrator…; name without TLD gets `.test`: install-options: Skip the WordPress install; shell metacharacters: install-options: Shell metacharacters in the site name are rejected | +| existing site / existing webroot / name that is an alias | site: Creating an existing site fails…; database: An existing webroot aborts the create…; alias-domains: An alias of another site cannot be created as a site; an existing site's name as an alias on create: not covered yet: it is accepted because create only checks other sites' aliases (its scenario comes back with the fix) | +| `--cache` | cache: Create a site with page and object cache…, The page cache is served from redis; subdom + cache: cache: Subdomain multisite with cache…; EE3 `--wpredis`: install-options | +| `--vip`, `--vip=` | vip: Create a VIP site from the default skeleton with cache; inaccessible repo: vip: An inaccessible VIP repository is rejected… | +| `--mu=subdir` / `--mu=subdom` | multisite: Create a subdirectory multisite / Create a subdomain multisite; invalid value: Unknown multisite type…; no value: not covered yet: `--mu` without a value only warns and creates a single site (its scenario comes back with the fix); subdom + inherit: Subdomain multisite cannot inherit SSL, A rejected inherit + subdom create leaves nothing in the global database; subdom wildcard that is another site's alias: multisite regression scenario | +| `--alias-domains` | alias-domains: Create a site with alias domains (incl. spaces), conflicts on create, subdom + wildcard alias; `_wildcard.` alias and blank alias: regression scenarios (fixed by EasyEngine/site-command#502 and EasyEngine/site-type-wp#239) | +| `--title` | install-options: Custom title…; apostrophe: not covered yet: an apostrophe in the title breaks the unescaped `wp core install` command (its scenario comes back with the fix); default (= site name): site: Create a default WordPress site | +| `--admin-user`, `--admin-email`, `--admin-pass` | install-options: Custom title…; defaults (random `wp-user-*`, 18-char password, `admin@`): site: Create a default WordPress site | +| password policy / `--allow-insecure-pass` | install-options: Weak administrator password… (3 rules); Insecure password allowed… | +| `--local-db` | database: Create a site with a local database container, restart `--db`, disable/enable, delete | +| `--with-local-redis` | cache: Create a site with cache on a local redis container; alone: not covered yet: `--with-local-redis` without `--cache` is silently ignored (its scenario comes back with the fix); restart policy: not covered yet: the local redis service has no restart policy (its scenario comes back with the fix) | +| `--public-dir` | install-options: Serve WordPress from a public directory…; without value: not covered yet: a bare `--public-dir` makes the public dir `htdocs/1` (its scenario comes back with the fix) | +| `--php` (all 12 options) | 8.4 default and update to 8.2: php-version: Default PHP version and update…; 8.3, 8.5, latest: Create a site on PHP ; 5.6, 7.0, 7.2, 7.3, 7.4, 8.0, 8.1: `@slow` outlines; invalid (7.1, 9.9, foo): Unsupported PHP version…; loose matches (8, 8.10): not covered yet: versions are compared loosely, so `--php=8` passes validation (its outline comes back with the fix) | +| `--dbname`, `--dbuser`, `--dbpass` | database: Custom database name…, Deleting the site drops its custom database and user; remote: external-db; collisions: database ×3 | +| `--dbhost` (+ `:port`) | database: Remote database host requires credentials, Unreachable remote database rolls the site back; external-db: all scenarios | +| `--dbprefix`, `--dbcharset`, `--dbcollate`, `--skip-check` | database: Custom database name, user, password, prefix, charset and collation… | +| `--version` | install-options: Install a specific WordPress version; php-version legacy outlines (6.2.6, 6.8.3) | +| `--skip-content` | install-options: Install WordPress without the default themes and plugins | +| `--skip-install` | install-options: Skip the WordPress install; used as a fast fixture in many scenarios | +| `--skip-status-check` | install-options: Insecure password allowed, status check skipped… | +| `--ssl=self` | ssl: Create a site with a self-signed certificate; turning it off again: ssl (regression check) | +| `--ssl=custom`, `--ssl-key`, `--ssl-crt` | ssl: Create a site with a custom certificate; missing flags; missing files; rollback dropping another site's DB: database | +| `--ssl=inherit` | ssl: A subdomain site inherits…, Inherit without a parent site rolls back, with `--wildcard`; parent without SSL: not covered yet: the error dumps the parent site object with `var_dump()` (its scenario comes back with the fix) | +| `--ssl=le` | ssl: Create a WordPress site with a Let's Encrypt certificate (`@le`); leftovers after delete: ssl (regression check) | +| unknown `--ssl` value | ssl: An unknown --ssl value rolls the whole create back (late rejection: the value is only checked after the site was provisioned) | +| `--ssl` without value (= le) | not covered: same code path as `--ssl=le`, LE budget | +| `--wildcard` | ssl: Inherit together with --wildcard is rejected; update `--ssl=le --wildcard` rejection; `--ssl=le --wildcard` itself not covered (needs Cloudflare DNS-01 credentials) | +| `--proxy-cache=on\|off` | proxy-cache: Create a site with proxy cache…; invalid value: not covered yet: `on`/`off` are parsed as booleans, so any value passes validation (its scenario comes back with the fix); default stored as `''`: not covered yet: a new site stores `''` instead of `off` (its scenario comes back with the fix) | +| `--proxy-cache-max-size`, `--proxy-cache-max-time` | proxy-cache: Create a site with proxy cache and custom limits (valid time, invalid size fallback) | +| `--force` | external-db: A remote database that already has tables needs --force | +| `--yes` | not covered: on create it only answers the VIP "move mu-plugins" prompt (needs a repo with `mu-plugins`) and the PHP fallback prompt, which the synopsis validation makes unreachable | +| global `--locale` | install-options: Localized install on a staging server… | +| global `--wp-mail` | install-options: Insecure password allowed… (lower-cased default e-mail) | +| global `--env` | install-options: production (WP_ENVIRONMENT_TYPE, no mailhog), staging (mailhog enabled) | +| EE3 flags `--wpredis --user --pass --email`, removed `--w3tc` | install-options: EasyEngine v3 flags…, Removed EasyEngine v3 cache flags… | + +### Other subcommands defined or overridden by the wp type + +| Item | Covered by | +|---|---| +| `info ` table | site: Site info as a table; cache/proxy/SSL/local-db/alias/skip-install variants in their features | +| `info --format=json` | site: Site info as JSON (and every `the site … info should contain` step) | +| `info` invalid format / unknown site / from site dir / disabled site | site: Invalid info format…, Site info of an unknown site fails, Site info detects the site from the current directory, Disable and enable… | +| `restart [--all] [--nginx] [--php] [--db]` | site: Restart and reload…; `--db`: database: Restart of a local-db site…; disabled site refused: site: Disable and enable… | +| `reload [--all] [--nginx] [--php]` | site: Restart and reload…; disabled site refused | +| `share --disable` (no share) / SSL site | site: Share without an active share…; ssl: self-signed scenario | +| `share` with ngrok, `--refresh`, `--token` | not covered: needs the external ngrok service and a token | +| `update --ssl=self\|off` (WP search-replace + DB backup) | ssl: Enabling and disabling SSL… ; invalid/`--wildcard`: Invalid SSL updates are rejected; off after a self-signed create: ssl (regression check) | +| `update --php` | php-version: Default PHP version and update…, same version, unsupported version | +| `update --proxy-cache[=on\|off]`, `--proxy-cache-max-*`, `--proxy-cache-key-zone-size` | proxy-cache: Proxy cache can be turned off…, Enable proxy cache on an existing site with update; invalid value: not covered yet (same boolean parsing as on create; its scenario comes back with the fix) | +| `update --add-alias-domains` / `--delete-alias-domains` | alias-domains: Add and delete alias domains…, Another site cannot be added as an alias; nohttps kept on sites without SSL: ×2 (regression checks) | +| `update` of unknown / disabled site | site: Update of an unknown site fails, Disable and enable… | +| `enable` / `disable` / `refresh` | site: Disable and enable…, Refresh regenerates docker-compose.yml…; local-db: database | +| `delete` (+ confirmation prompt) | site: Delete removes every trace… (incl. the random DB user), Declining the delete confirmation…, Deleting a missing site fails; redirect/proxy/alias/local-db/custom-cert/LE cleanup in their features | +| `list [--format] [--enabled] [--disabled]` | site: List shows the site in every format; disabled: site: Disable and enable… | +| `clean [--page] [--object] [--proxy]` | cache and proxy-cache (regression checks: `ee site clean` failed without a TTY yet printed Success, fixed in v4.13.2 by EasyEngine/site-command#510); errors: site: Cache clean on a site without cache fails, cache: Cache clean without proxy cache… | + +### Behaviours and side effects + +| Behaviour | Covered by | +|---|---| +| Webroot layout, `wp-config.php` one level above the docroot, no leftover `check.html` | site: Create a default WordPress site; public-dir variant | +| Generated files: `.env`, `main.conf` (php / redis / subdir blocks, server_name, root), `custom.ini` (msmtp vs 5.6 sendmail), `user.conf`, `admin-tools.conf`, `docker-compose.yml`, `wp-cli.yml` | site, cache, multisite, php-version, install-options | +| Containers (3 running, mailhog/db/redis absent), images, restart policy, network membership (frontend/backend), site network, 8/11 labelled volumes | site, labels, database, cache | +| Global DB database + user created and dropped; nothing created on early validation errors | site, database, php-version, install-options | +| `/etc/hosts` entry for non-SSL/self sites only, removed on delete | site, ssl, install-options | +| Cron entry created and removed | site, install-options | +| WordPress install: home URL (http/https), permalinks, admin user, title, locale, version, content, `WP_ENVIRONMENT_TYPE`, `COOKIE_DOMAIN` for multisite, X-Forwarded-Proto/debug extras | site, ssl, install-options, multisite | +| nginx WordPress hardening (readme/license/wp-config.txt/dotfiles/.sql/php in uploads) | site: WordPress hardening rules… | +| www / non-www redirect config (only when the other name resolves) | redirect | +| Object/page cache: plugins, wp-config constants, nginx-helper options (option vs network meta), srcache HIT/BYPASS, redis keys | cache | +| Proxy cache files in nginx-proxy `conf.d`/`vhost.d`, `X-Proxy-Cache` header | proxy-cache | +| Rollback (`catch_clean`) at levels 0–4: webroot, containers, volumes, DB, hosts entry removed; remote DB never touched | database, external-db, ssl | +| VIP: skeleton clone, mu-plugins move, wp-config requires, site meta, ownership | vip | +| Container migrations (`set_mu_cookie_domain`, `update_env_file_with_newrelic_key`) | not covered: they only run on upgrade from an older EE; new sites get the same result directly (checked) | +| Rollback on SIGINT/SIGTERM | not covered: the signal handlers are never dispatched; needs killing ee mid-create | +| macOS (`IS_DARWIN`) branches | not covered: Linux-only host | + +Estimated coverage: 61 of 67 inventory rows above are covered (about 90%; `--wildcard` only partly); the gaps are the external-service/DNS-01 paths (`--ssl` bare, `--ssl=le --wildcard`, ngrok share, `--yes`), upgrade-only migrations, signal rollback and macOS branches. + +## Known bugs and regression scenarios + +The alias-name scenarios (an alias that could clobber the site's `_wildcard` proxy files, a subdomain multisite whose wildcard is another site's alias, a trailing comma in `--alias-domains`) are regression checks for EasyEngine/site-command#502 and EasyEngine/site-type-wp#239. The name-collision scenarios (database: name collision, user collision, failed custom-SSL create, existing webroot; multisite: rejected inherit + subdom) check EasyEngine/site-command#503, EasyEngine/site-type-wp#240 and EasyEngine/site-type-php#115. The alias-change, www-redirect-after-delete, `--ssl=off` after a self-signed create and LE-leftover scenarios check EasyEngine/site-command#505; the alias-change scenario waits for the new alias to be served before checking, because the alias update returns before docker-gen has the recreated container. + +Scenarios for open bugs are left out of this suite until their fixes land; each comes back as a regular scenario with its fix: + +- alias-domains: An existing site cannot be reused as an alias on create +- cache: The local redis container restarts with the site +- cache: --with-local-redis without --cache enables the local redis cache +- multisite: --mu without a value is rejected +- proxy-cache: Invalid proxy-cache value on update / on create is rejected +- proxy-cache: A new site stores proxy_cache off and refuses to turn it off again +- php-version: Loosely matching PHP version is rejected (2 examples) +- ssl: Inherit from a parent without SSL fails with a clean message +- install-options: --public-dir without a value keeps the default webroot +- install-options: A site title with an apostrophe + +Regression scenarios for fixed bugs: install-options: Shell metacharacters in the site name are rejected (fixed in v4.13.2 by EasyEngine/site-type-wp#243 and EasyEngine/site-command#509); install-options: The latest WordPress download passes its checksums (fixed in v4.13.1 by EasyEngine/site-type-wp#241); cache: ee site clean purges…, proxy-cache: ee site clean --proxy empties… (fixed in v4.13.2 by EasyEngine/site-command#510). + +## Tags and runtime + +| Tag | Scenarios | Meaning | +|---|---|---| +| (all) | 116 | including outline examples (main lane 107, `@slow` 7, `@le` 2) | +| `@destructive` | 4 | proxy-cache enable/disable restarts the global nginx-proxy (all sites blip) | +| `@le` | 2 | one real Let's Encrypt issuance (`bh-site-type-wp-le1.ee4-test.rt.gw`); the second scenario only inspects its leftovers and refuses to run without the first. On CI both run against Pebble (`.github/behat/pebble-setup.sh`); the leftovers one is a regression check for EasyEngine/site-command#505 | +| `@le-real` | 0 | needs the public LE service itself; none, both `@le` scenarios work on Pebble | +| `@slow` | 7 | legacy PHP images (5.6–8.1), pulled on first use | +| `@external-db` | 3 | throwaway MariaDB on the host network bound to the docker bridge gateway (read from `docker network inspect bridge`); reachability from the default bridge and from a user-defined network was checked with a throwaway listener | +| `@network` | 2 | VIP: clones from github.com | +| `@remote` | 0 | no rclone use in this package | + +Rough timings: full WP create 60–90 s, `--skip-install` create 25–40 s, delete 10–15 s. Default run (`--tags='~@slow&&~@le&&~@external-db&&~@network'`, ~26 full and ~12 skip-install creates): about 50 minutes. `@slow` adds 10–15 minutes plus image pulls, `@external-db` ~5, `@network` ~5, `@le` ~3. + +## Context changes (`bootstrap/FeatureContext.php`) + +Based on site-command's context. Fixed upstream steps that could never fail: the unanchored `I run` pattern (matched any step with a double-quoted string and passed an empty command), `There should be N containers with labels` (inverted, int vs string), `Following containers … should be removed` (compose v1 container names), `I create subsite` (swapped arguments, `docker exec -it`), the `of command :index` regex steps. `EE_ROOT_DIR` is only defined when core has not defined it. The cleanup hooks no longer delete a fixed list of generic site names. New generic steps are in the "Shared generic steps" section; they assert on the last command and print the full command output on failure. `the site :site was deleted earlier in this feature` guards scenarios that inspect another scenario's leftovers, so they fail instead of passing vacuously when run alone. diff --git a/features/alias-domains.feature b/features/alias-domains.feature new file mode 100644 index 0000000..8f6458f --- /dev/null +++ b/features/alias-domains.feature @@ -0,0 +1,169 @@ +Feature: Alias domains of WordPress sites (--alias-domains, --add/--delete-alias-domains) + + Scenario: Create a site with alias domains + When I run "bin/ee site create bh-site-type-wp-alias.test --type=wp --alias-domains='bh-site-type-wp-alias-a.test, bh-site-type-wp-alias-b.test'" + Then the command should succeed + And the site 'bh-site-type-wp-alias.test' info should contain: + | field | value | + | alias_domains | bh-site-type-wp-alias.test,bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test | + And the file '{SITES_DIR}/bh-site-type-wp-alias.test/docker-compose.yml' should contain: + """ + VIRTUAL_HOST=${VIRTUAL_HOST},bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test + CERT_NAME=${VIRTUAL_HOST} + """ + And the file '{SITES_DIR}/bh-site-type-wp-alias.test/config/nginx/conf.d/main.conf' should contain: + """ + server_name bh-site-type-wp-alias.test bh-site-type-wp-alias-a.test bh-site-type-wp-alias-b.test; + """ + And the hosts file should contain 'bh-site-type-wp-alias.test' + And the hosts file should not contain 'bh-site-type-wp-alias-a.test' + And a GET request to 'http://bh-site-type-wp-alias.test/' should return 200 + And a GET request to 'http://bh-site-type-wp-alias-a.test/' should not return 503 + And a GET request to 'http://bh-site-type-wp-alias-b.test/' should not return 503 + When I run 'bin/ee site info bh-site-type-wp-alias.test' + Then STDOUT should contain: + """ + bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test + """ + + Scenario: An alias of another site cannot be created as a site + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run 'bin/ee site create bh-site-type-wp-alias-a.test --type=wp' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-alias-a.test already exists as an alias domain for site: bh-site-type-wp-alias.test. + """ + And the site 'bh-site-type-wp-alias-a.test' should not exist + + Scenario: An existing alias cannot be reused as an alias on create + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run "bin/ee site create bh-site-type-wp-alias2.test --type=wp --alias-domains='bh-site-type-wp-alias-b.test'" + Then the command should fail + And STDERR should contain: + """ + already exists as an alias domain for site: bh-site-type-wp-alias.test + """ + And the site 'bh-site-type-wp-alias2.test' should not exist + + Scenario: Add and delete alias domains on a WordPress site + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias-c.test' + Then the command should succeed + And STDOUT should contain: + """ + Success: Alias domains updated on site bh-site-type-wp-alias.test. + """ + And the site 'bh-site-type-wp-alias.test' info should contain: + | field | value | + | alias_domains | /,bh-site-type-wp-alias-c\.test$/ | + And the file '{SITES_DIR}/bh-site-type-wp-alias.test/docker-compose.yml' should contain: + """ + bh-site-type-wp-alias-c.test + """ + And a GET request to 'http://bh-site-type-wp-alias-c.test/' should not return 503 within 30 seconds + When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias-c.test' + Then the command should fail + And STDERR should contain: + """ + Alias domains: bh-site-type-wp-alias-c.test is/are already present on the site. + """ + When I run 'bin/ee site update bh-site-type-wp-alias.test --delete-alias-domains=bh-site-type-wp-alias-zzz.test' + Then the command should fail + And STDERR should contain: + """ + Domains to delete is/are not a subset of already existing alias domains. + """ + When I run 'bin/ee site update bh-site-type-wp-alias.test --delete-alias-domains=bh-site-type-wp-alias.test' + Then the command should fail + And STDERR should contain: + """ + Primary site domain: `bh-site-type-wp-alias.test` can not be deleted. + """ + When I run 'bin/ee site update bh-site-type-wp-alias.test --delete-alias-domains=bh-site-type-wp-alias-c.test' + Then the command should succeed + And the site 'bh-site-type-wp-alias.test' info should contain: + | field | value | + | alias_domains | /^((?!alias-c).)*$/ | + And a GET request to 'http://bh-site-type-wp-alias-c.test/' should return 503 within 30 seconds + + Scenario: Another site cannot be added as an alias + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + And a WordPress site 'bh-site-type-wp-alias3.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-alias.test --add-alias-domains=bh-site-type-wp-alias3.test' + Then the command should fail + And STDERR should contain: + """ + bh-site-type-wp-alias3.test already exists as a site. + """ + When I run 'bin/ee site update bh-site-type-wp-alias3.test --add-alias-domains=bh-site-type-wp-alias-a.test' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-alias-a.test already exists as an alias domain for site: bh-site-type-wp-alias.test. + """ + + Scenario: Deleting the site frees its alias domains + Given a WordPress site 'bh-site-type-wp-alias.test' exists created with "--alias-domains='bh-site-type-wp-alias-a.test,bh-site-type-wp-alias-b.test'" + When I run 'bin/ee site delete bh-site-type-wp-alias.test --yes' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-alias-a.test/' should return 503 within 30 seconds + When I run 'bin/ee site create bh-site-type-wp-alias-a.test --type=wp --skip-install' + Then the command should succeed + + Scenario: Subdomain multisite with a wildcard alias domain + When I run "bin/ee site create bh-site-type-wp-aliasmu.test --type=wp --mu=subdom --skip-install --alias-domains='bh-site-type-wp-aliasmu-x.test,*.bh-site-type-wp-aliasmu-x.test'" + Then the command should succeed + And the site 'bh-site-type-wp-aliasmu.test' info should contain: + | field | value | + | alias_domains | bh-site-type-wp-aliasmu.test,*.bh-site-type-wp-aliasmu.test,bh-site-type-wp-aliasmu-x.test,*.bh-site-type-wp-aliasmu-x.test | + And the file '{SITES_DIR}/bh-site-type-wp-aliasmu.test/docker-compose.yml' should contain: + """ + VIRTUAL_HOST=${VIRTUAL_HOST},*.${VIRTUAL_HOST}, + *.bh-site-type-wp-aliasmu-x.test + """ + And a GET request to 'http://foo.bh-site-type-wp-aliasmu-x.test/' should not return 503 + + # Regression check: an alias like `_wildcard.` could clobber the site's `_wildcard` proxy files (fixed by EasyEngine/site-command#502 and EasyEngine/site-type-wp#239). + Scenario: Reserved alias names are rejected on create + When I run "bin/ee site create bh-site-type-wp-auth1.test --type=wp --skip-install --alias-domains='_wildcard.bh-site-type-wp-auth1.test'" + Then the command should fail + And the site 'bh-site-type-wp-auth1.test' should not exist + + # Regression check (fixed by EasyEngine/site-type-wp#239): a trailing comma stored an empty alias and a dangling `,` in VIRTUAL_HOST. + Scenario: Blank alias domains are dropped on create + When I run "bin/ee site create bh-site-type-wp-blank.test --type=wp --skip-install --alias-domains='bh-site-type-wp-blank-a.test,'" + Then the command should succeed + And the site 'bh-site-type-wp-blank.test' info should contain: + | field | value | + | alias_domains | bh-site-type-wp-blank.test,bh-site-type-wp-blank-a.test | + + # Regression check (fixed by EasyEngine/site-command#505): update_alias_domains() dumped docker-compose.yml without HTTPS_METHOD=nohttps for a site without SSL, until the next refresh. + Scenario: Alias changes keep HTTPS disabled on a site without SSL + Given a WordPress site 'bh-site-type-wp-aliasnossl.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-aliasnossl.test --add-alias-domains=bh-site-type-wp-aliasnossl-a.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-aliasnossl.test/docker-compose.yml' should contain: + """ + HTTPS_METHOD=nohttps + """ + When I run 'bin/ee site update bh-site-type-wp-aliasnossl.test --delete-alias-domains=bh-site-type-wp-aliasnossl-a.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-aliasnossl.test/docker-compose.yml' should contain: + """ + HTTPS_METHOD=nohttps + """ + + # Regression check (fixed by EasyEngine/site-command#505): `--ssl=off` kept .crt, and with aliases CERT_NAME=${VIRTUAL_HOST} picked it up, so without nohttps nginx-proxy redirected HTTP to HTTPS again. + Scenario: A site whose SSL was turned off keeps serving HTTP after an alias change + Given a WordPress site 'bh-site-type-wp-aliasoff.test' exists + When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --ssl=self' + Then the command should succeed + When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --ssl=off' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-aliasoff.test/' should return 200 within 30 seconds + When I run 'bin/ee site update bh-site-type-wp-aliasoff.test --add-alias-domains=bh-site-type-wp-aliasoff-a.test' + Then the command should succeed + # The update returns before docker-gen has the recreated container, so the old (HTTP) config could still answer: wait until the new alias is served. + When I wait until "curl -s -o /dev/null -D - --resolve bh-site-type-wp-aliasoff-a.test:80:127.0.0.1 http://bh-site-type-wp-aliasoff-a.test/ | head -n 1 | grep -qv ' 503'" succeeds within 60 seconds + Then a GET request to 'http://bh-site-type-wp-aliasoff.test/' should return 200 diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 94c34d8..524c7bc 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -14,7 +14,18 @@ define( 'EE', true ); define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); -define( 'EE_ROOT_DIR', '/opt/easyengine' ); + +$root_alias = '/opt'; +if ( 'Darwin' === php_uname( 's' ) ) { + $sys_name = posix_getpwuid(posix_geteuid())['name']; + if ( ! empty( $sys_name ) ) { + $root_alias = '/Users/' . $sys_name; + } +} +// Core's init-ee.php (pulled in by the includes above) may already have defined it. +if ( ! defined( 'EE_ROOT_DIR' ) ) { + define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +} require_once EE_ROOT . '/php/bootstrap.php'; @@ -47,6 +58,7 @@ use Behat\Behat\Context\Context; use Behat\Behat\Hook\Scope\AfterFeatureScope; use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Behat\Hook\Scope\BeforeFeatureScope; use Behat\Gherkin\Node\PyStringNode, Behat\Gherkin\Node\TableNode; @@ -55,10 +67,40 @@ class FeatureContext implements Context { + /** + * Every site, docker object, DB name/user, hosts line and temp path created by this suite contains this marker. + * Cleanup only ever touches things that contain it, so suites of other packages are never affected. + */ + const TEST_PREFIX = 'bh-site-type-wp-'; + + /** Global DB/user names are derived from site names with `.` and `-` replaced by `_`. */ + const TEST_DB_PREFIX = 'bh_site_type_wp_'; + + /** Scratch directory for generated certificates and similar fixtures. */ + const TEST_TMP_DIR = '/var/tmp/bh-site-type-wp'; + + const GLOBAL_DB_CONTAINER = 'services_global-db_1'; + const GLOBAL_REDIS_CONTAINER = 'services_global-redis_1'; + public $command; public $webroot_path; public $ee_path; + /** @var array Results (EE\ProcessRun) of every command run in the current scenario. */ + public $commands = []; + + /** @var array|null Last HTTP response: code, headers, body, url. */ + private $response; + + /** @var array Lines appended to /etc/hosts by the current scenario. */ + private $added_hosts_lines = []; + + /** @var array Container start times noted by the current scenario, keyed by "site/service". */ + private $noted_start_times = []; + + /** @var array Sites removed by a successful `bin/ee site delete` in the current feature. */ + private static $deleted_sites = []; + /** * Initializes context. */ @@ -102,11 +144,19 @@ public function isInstalled($command) } /** - * @When /I run '(.*)'|"(.*)"/ + * The upstream pattern `/I run '(.*)'|"(.*)"/` is unanchored, so it matched every step containing a double-quoted + * string and passed an empty command for the double-quoted form. Split into two anchored definitions. + * + * @When /^I run '(.*)'$/ + * @When /^I run "(.*)"$/ */ public function iRun($command) { - $this->commands[] = EE::launch($command, false, true); + $result = EE::launch( $this->replace_placeholders( $command ), false, true ); + $this->commands[] = $result; + if ( 0 === $result->return_code && preg_match( '#^bin/ee site delete (\S+)#', $command, $matches ) ) { + self::$deleted_sites[] = $matches[1]; + } } /** @@ -114,9 +164,9 @@ public function iRun($command) */ public function iCreateSubsiteInOfType($subsite, $site ) { - $php_container = implode( explode( '.', $subsite ) ) . '_php_1'; - - EE::launch( "docker exec -it --user='www-data' $php_container sh -c 'wp site create --slug=$site'", false, true ); + // Upstream swapped the arguments and used `docker exec -it`, so it never created a subsite. + $this->run_wp_cli( $site, 'site create --slug=' . escapeshellarg( $subsite ) ); + $this->assert_last_command_succeeded(); } /** @@ -125,17 +175,17 @@ public function iCreateSubsiteInOfType($subsite, $site ) public function returnValueShouldBe0() { if ( 0 !== $this->commands[0]->return_code ) { - throw new Exception("Actual return code is not zero: \n" . $this->command); + throw new Exception("Actual return code is not zero: \n" . $this->describe_command( $this->commands[0] )); } } /** - * @Then return value of command :index should be 0 + * @Then /^return value of command (\d+) should be 0$/ */ public function returnValueOfCommandShouldBe0($index) { if ( 0 !== $this->commands[ $index - 1 ]->return_code ) { - throw new Exception("Actual return code is not zero: \n" . $this->command); + throw new Exception("Actual return code is not zero: \n" . $this->describe_command( $this->commands[ $index - 1 ] )); } } @@ -148,7 +198,7 @@ public function afterDelayOfSeconds( $time ) } /** - * @Then /(STDOUT|STDERR) should return exactly/ + * @Then /^(STDOUT|STDERR) should return exactly$/ */ public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) { @@ -162,7 +212,7 @@ public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected } /** - * @Then /(STDOUT|STDERR) of command :index should return exactly/ + * @Then /^(STDOUT|STDERR) of command (\d+) should return exactly$/ */ public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStringNode $expected_output) { @@ -170,35 +220,31 @@ public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStr $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if ( $expected_out !== trim($command_output)) { + if ( $expected_output->getRaw() !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); } } /** - * @Then /(STDOUT|STDERR) should return something like/ + * @Then /^(STDOUT|STDERR) should return something like$/ */ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) { $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } - /** - * @Then /(STDOUT|STDERR) of command :index should return something like/ + * @Then /^(STDOUT|STDERR) of command (\d+) should return something like$/ */ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, PyStringNode $expected_output) { $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } @@ -208,11 +254,11 @@ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, */ public function theSiteShouldBeMultisite( $site, $type ) { - $result = EE::launch("cd " . EE_SITE_ROOT . "/$site && docker-compose exec --user='www-data' php sh -c 'wp config get SUBDOMAIN_INSTALL'", false, true ); + $result = $this->run_wp_cli( $site, 'config get SUBDOMAIN_INSTALL' ); -// if( $result->stderr ) { -// throw new Exception("Found error while executing command: $result->stderr"); -// } + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not read SUBDOMAIN_INSTALL:\n" . $this->describe_command( $result ) ); + } if($type === 'subdomain' && trim($result->stdout) !== '1') { throw new Exception("Expecting SUBDOMAIN_INSTALL to be 1. Got: $result->stdout"); @@ -230,11 +276,9 @@ public function theSiteShouldBeMultisite( $site, $type ) */ public function theDbEntryShouldBeRemoved($site) { - $out = shell_exec("sudo bin/ee site list"); - if (strpos($out, $site) !== false) { + if ( $this->site_row( $site ) ) { throw new Exception("$site db entry not been removed!"); } - } /** @@ -252,17 +296,11 @@ public function theWebrootShouldBeRemoved($site) */ 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!"); + // Upstream looked up `__1`, which never matches compose v2 names, so it always passed. + foreach ( $table->getHash() as $container ) { + $id = $this->site_container_id( $site, $container['container'] ); + if ( '' !== $id ) { + throw new Exception( "The {$container['container']} container of $site has not been removed (id $id)." ); } } } @@ -282,7 +320,7 @@ public function theSiteShouldHaveWebroot($site) */ public function theSiteShouldHaveWordpress($site) { - if ( ! file_exists( EE_SITE_ROOT . '/' . $site . "/app/wp-config.php" ) ) { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site . '/app/wp-config.php' ) ) { throw new Exception("WordPress data not found!"); } } @@ -292,25 +330,8 @@ public function theSiteShouldHaveWordpress($site) */ 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); - - 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); - } - } + $response = $this->http_request( 'http://' . $site . '/', null, true ); + $this->assert_headers_contain_rows( $response['headers'], $table ); } /** @@ -318,25 +339,9 @@ public function requestOnShouldContainFollowingHeaders($site, TableNode $table) */ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, 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_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_VERBOSE, true); - $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); - } - } + $url = preg_match( '#^https?://#', $host ) ? $host : 'http://' . $host . '/'; + $response = $this->http_request( $url, null, false, [ $header ] ); + $this->assert_headers_contain_rows( $response['headers'], $table ); } /** @@ -344,7 +349,6 @@ public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, */ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $resolve, TableNode $table) { - $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $host); curl_setopt($ch, CURLOPT_HEADER, true); @@ -353,17 +357,10 @@ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $ 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); $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); - } - } + $this->assert_headers_contain_rows( (string) $headers, $table ); } /** @@ -371,7 +368,10 @@ public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $ */ public function cleanupScenario(AfterScenarioScope $scope) { - $this->commands = []; + $this->remove_added_hosts_lines(); + $this->commands = []; + $this->response = null; + $this->noted_start_times = []; chdir($this->ee_path); } @@ -380,41 +380,77 @@ public function cleanupScenario(AfterScenarioScope $scope) */ public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) { - $labels = $pyStringNode->getStrings(); - $label_string = implode(' -f label=',$labels); + // Upstream compared a string to an int with `===` and threw on equality, so it could never fail. + $filters = ''; + foreach ( $pyStringNode->getStrings() as $label ) { + $filters .= ' --filter ' . escapeshellarg( 'label=' . trim( $label ) ); + } - $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); - $running_containers = (int) trim($result->stdout); + $result = EE::launch( "docker ps -q $filters | wc -l", false, true ); + $running_containers = (int) trim( $result->stdout ); - if($expected_running_containers === $running_containers) { - throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + if ( (int) $expected_running_containers !== $running_containers ) { + throw new Exception( "Expected $expected_running_containers running containers. Found: $running_containers" ); } } /** - * @AfterFeature + * Removes leftovers of an aborted earlier run before a feature starts. + * + * @BeforeFeature */ - public static function cleanup(AfterFeatureScope $scope) + public static function prepareFeature(BeforeFeatureScope $scope) { - $test_sites = [ - 'wp.test', - 'wpsubdom.test', - 'wpsubdir.test', - 'example.test', - 'www.example1.test', - 'example2.test', - 'www.example3.test', - 'labels.test', - 'wpcache.test', - ]; + self::$deleted_sites = []; + self::cleanup_test_artifacts(); + } - $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 ); + /** + * 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.' ); + } + } - foreach ( $sites_to_delete as $site ) { - exec("sudo bin/ee site delete $site --yes" ); + /** + * @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 (upstream deleted a fixed list of generic names) and restores shared state. + * + * @AfterFeature + */ + public static function cleanup(AfterFeatureScope $scope) + { + self::cleanup_test_artifacts(); if(file_exists('ee.phar')) { unlink('ee.phar'); @@ -423,4 +459,1242 @@ public static function cleanup(AfterFeatureScope $scope) unlink('ee-old.phar'); } } + + /* ------------------------------------------------------------------------------------------------------------ + * Shared generic steps. + * + * Generic steps meant to be shared across the EasyEngine package suites. Assertions operate on the LAST command + * run in the scenario (the upstream steps above only look at the first one) and print the actual output on failure. + * Placeholders usable in commands, paths and expected values: {EE_ROOT_DIR}, {SITES_DIR}, {TMP_DIR}, {DOCKER_BRIDGE_IP}. + * --------------------------------------------------------------------------------------------------------- */ + + /** + * @Given a WordPress site :site exists + */ + public function aWordpressSiteExists( $site ) + { + $this->aWordpressSiteExistsCreatedWith( $site, '' ); + } + + /** + * Creates the site only when it is missing, so scenarios stay independent but can reuse a site within a feature. + * + * @Given a WordPress site :site exists created with :flags + */ + public function aWordpressSiteExistsCreatedWith( $site, $flags ) + { + if ( $this->site_row( $site ) ) { + return; + } + $command = sprintf( 'bin/ee site create %s --type=wp %s', escapeshellarg( $site ), $this->replace_placeholders( $flags ) ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not create prerequisite site $site:\n" . $this->describe_command( $result ) ); + } + } + + /** + * Runs a command with the given working directory; a leading `bin/ee` is resolved against the EE checkout. + * + * @When From the directory :dir I run :command + */ + public function fromTheDirectoryIRun( $dir, $command ) + { + $command = $this->replace_placeholders( $command ); + if ( 0 === strpos( $command, 'bin/ee' ) ) { + $command = escapeshellarg( realpath( EE_ROOT ) . '/bin/ee' ) . substr( $command, strlen( 'bin/ee' ) ); + } + $this->commands[] = EE::launch( 'cd ' . escapeshellarg( $this->replace_placeholders( $dir ) ) . ' && ' . $command, false, true ); + } + + /** + * @When I run WP-CLI :command on site :site + */ + public function iRunWpCliOnSite( $command, $site ) + { + $this->run_wp_cli( $site, $this->replace_placeholders( $command ) ); + } + + /** + * @When In the :service container of site :site I run :command + */ + public function inTheContainerOfSiteIRun( $service, $site, $command ) + { + $id = $this->site_container_id( $site, $service, false ); + if ( '' === $id ) { + throw new Exception( "No running $service container found for $site." ); + } + $this->commands[] = EE::launch( sprintf( 'docker exec %s sh -c %s', $id, escapeshellarg( $this->replace_placeholders( $command ) ) ), false, true ); + } + + /** + * @When In the docker container :name I run :command + */ + public function inTheDockerContainerIRun( $name, $command ) + { + $this->commands[] = EE::launch( sprintf( 'docker exec %s sh -c %s', escapeshellarg( $name ), escapeshellarg( $this->replace_placeholders( $command ) ) ), false, true ); + } + + /** + * @When I wait until :command succeeds within :seconds seconds + */ + public function iWaitUntilSucceedsWithin( $command, $seconds ) + { + $command = $this->replace_placeholders( $command ); + $deadline = time() + (int) $seconds; + do { + // Each attempt gets only the time left, so a stalled command can't outlast the deadline. + $result = EE::launch( sprintf( 'timeout -k 5 %d sh -c %s', max( 1, $deadline - time() ), escapeshellarg( $command ) ), false, true ); + if ( 0 === $result->return_code ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "`$command` did not succeed within $seconds seconds. Last attempt:\n" . $this->describe_command( $result ) ); + } + + /** + * @Then the command should succeed + */ + public function theCommandShouldSucceed() + { + $this->assert_last_command_succeeded(); + } + + /** + * @Then the command should fail + */ + public function theCommandShouldFail() + { + $result = $this->last_command(); + if ( 0 === $result->return_code ) { + throw new Exception( "Expected a non-zero exit code.\n" . $this->describe_command( $result ) ); + } + } + + /** + * Every non-empty line of the PyString must appear in the stream (order-insensitive substring match). + * + * @Then /^(STDOUT|STDERR) should contain:$/ + */ + public function streamShouldContain( $stream, PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( $stream ), $expected, true, $stream ); + } + + /** + * @Then /^(STDOUT|STDERR) should not contain:$/ + */ + public function streamShouldNotContain( $stream, PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( $stream ), $expected, false, $stream ); + } + + /** + * STDOUT and STDERR combined; use it where EE's choice of stream is not what is being tested. + * + * @Then the output should contain: + */ + public function theOutputShouldContain( PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( 'BOTH' ), $expected, true, 'output' ); + } + + /** + * @Then the output should not contain: + */ + public function theOutputShouldNotContain( PyStringNode $expected ) + { + $this->assert_output_lines( $this->stream_output( 'BOTH' ), $expected, false, 'output' ); + } + + /** + * @Then STDOUT should be valid json + */ + public function stdoutShouldBeValidJson() + { + json_decode( trim( $this->last_command()->stdout ) ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + throw new Exception( 'STDOUT is not valid JSON (' . json_last_error_msg() . "):\n" . $this->last_command()->stdout ); + } + } + + /** + * @Then STDOUT should match :regex + */ + public function stdoutShouldMatch( $regex ) + { + if ( ! preg_match( $regex, $this->last_command()->stdout ) ) { + throw new Exception( "STDOUT does not match $regex:\n" . $this->last_command()->stdout ); + } + } + + /** + * @Then the site :site should exist + */ + public function theSiteShouldExist( $site ) + { + if ( ! $this->site_row( $site ) ) { + throw new Exception( "Site $site has no row in the EE database." ); + } + } + + /** + * @Then the site :site should not exist + */ + public function theSiteShouldNotExist( $site ) + { + if ( $this->site_row( $site ) ) { + throw new Exception( "Site $site still has a row in the EE database." ); + } + } + + /** + * @Then the site :site should be enabled + */ + public function theSiteShouldBeEnabled( $site ) + { + $this->assert_site_field( $site, 'site_enabled', '1' ); + } + + /** + * @Then the site :site should be disabled + */ + public function theSiteShouldBeDisabled( $site ) + { + $this->assert_site_field( $site, 'site_enabled', '0' ); + } + + /** + * Compares `ee site info --format=json` fields. Values: literal, ``, `` or `/regex/`. + * + * @Then the site :site info should contain: + */ + public function theSiteInfoShouldContain( $site, TableNode $table ) + { + $result = EE::launch( 'bin/ee site info ' . escapeshellarg( $site ) . ' --format=json', false, true ); + $info = json_decode( trim( $result->stdout ), true ); + if ( ! is_array( $info ) ) { + throw new Exception( "Could not read site info of $site:\n" . $this->describe_command( $result ) ); + } + + $errors = []; + foreach ( $table->getHash() as $row ) { + $field = $row['field']; + if ( ! array_key_exists( $field, $info ) ) { + $errors[] = "field `$field` is missing"; + continue; + } + $actual = $this->scalar_to_string( $info[ $field ] ); + if ( ! $this->value_matches( $actual, $this->replace_placeholders( $row['value'] ) ) ) { + $errors[] = "`$field` is " . var_export( $actual, true ) . ', expected ' . $row['value']; + } + } + + if ( $errors ) { + throw new Exception( "Site info mismatch for $site:\n - " . implode( "\n - ", $errors ) . "\nFull info: " . trim( $result->stdout ) ); + } + } + + /** + * @Then the EE database table :table should have :count rows where :column is :value + */ + public function theEeDatabaseTableShouldHaveRowsWhere( $table, $count, $column, $value ) + { + $this->assert_identifier( $table ); + $this->assert_identifier( $column ); + $statement = $this->ee_db()->prepare( "SELECT COUNT(*) FROM $table WHERE $column = ?" ); + $statement->execute( [ $this->replace_placeholders( $value ) ] ); + $actual = (int) $statement->fetchColumn(); + if ( (int) $count !== $actual ) { + throw new Exception( "Expected $count rows in `$table` where $column = $value, found $actual." ); + } + } + + /** + * @Then the site :site should have the site meta :key set to :value + */ + public function theSiteShouldHaveTheSiteMetaSetTo( $site, $key, $value ) + { + $statement = $this->ee_db()->prepare( 'SELECT m.meta_value FROM site_meta m JOIN sites s ON s.id = m.site_id WHERE s.site_url = ? AND m.meta_key = ?' ); + $statement->execute( [ $site, $key ] ); + $values = $statement->fetchAll( PDO::FETCH_COLUMN ); + if ( 1 !== count( $values ) || ! $this->value_matches( (string) $values[0], $value ) ) { + throw new Exception( "Site meta `$key` of $site is " . var_export( $values, true ) . ", expected $value." ); + } + } + + /** + * @Then the file :path should exist + */ + public function theFileShouldExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + } + + /** + * @Then the file :path should not exist + */ + public function theFileShouldNotExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( file_exists( $path ) ) { + throw new Exception( "File $path exists." ); + } + } + + /** + * @Then the directory :path should exist + */ + public function theDirectoryShouldExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( ! is_dir( $path ) ) { + throw new Exception( "Directory $path does not exist." ); + } + } + + /** + * @Then the directory :path should not exist + */ + public function theDirectoryShouldNotExist( $path ) + { + $path = $this->replace_placeholders( $path ); + if ( file_exists( $path ) ) { + throw new Exception( "Directory $path exists." ); + } + } + + /** + * @Then the file :path should contain: + */ + public function theFileShouldContain( $path, PyStringNode $expected ) + { + $this->assert_output_lines( $this->read_file( $path ), $expected, true, "file $path" ); + } + + /** + * @Then the file :path should not contain: + */ + public function theFileShouldNotContain( $path, PyStringNode $expected ) + { + $this->assert_output_lines( $this->read_file( $path ), $expected, false, "file $path" ); + } + + /** + * @Then the files :first and :second should be identical + */ + public function theFilesShouldBeIdentical( $first, $second ) + { + if ( $this->read_file( $first ) !== $this->read_file( $second ) ) { + throw new Exception( "$first and $second differ." ); + } + } + + /** + * @Given a self-signed certificate for :cn exists in :dir + */ + public function aSelfSignedCertificateForExistsIn( $cn, $dir ) + { + $dir = $this->replace_placeholders( $dir ); + if ( ! is_dir( $dir ) && ! mkdir( $dir, 0700, true ) ) { + throw new Exception( "Could not create $dir." ); + } + $command = sprintf( + 'openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj %s -addext %s -keyout %s -out %s', + escapeshellarg( '/CN=' . $cn ), + escapeshellarg( 'subjectAltName=DNS:' . $cn ), + escapeshellarg( "$dir/$cn.key" ), + escapeshellarg( "$dir/$cn.crt" ) + ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not generate a certificate:\n" . $this->describe_command( $result ) ); + } + } + + /** + * @Then the :service container of site :site should be running + */ + public function theContainerOfSiteShouldBeRunning( $service, $site ) + { + $id = $this->site_container_id( $site, $service ); + if ( '' === $id ) { + throw new Exception( "No $service container exists for $site." ); + } + $state = trim( EE::launch( "docker inspect -f '{{.State.Status}}' $id", false, true )->stdout ); + if ( 'running' !== $state ) { + throw new Exception( "The $service container of $site is $state, expected running." ); + } + } + + /** + * @Then the :service container of site :site should not exist + */ + public function theContainerOfSiteShouldNotExist( $service, $site ) + { + $id = $this->site_container_id( $site, $service ); + if ( '' !== $id ) { + throw new Exception( "The $service container of $site exists (id $id)." ); + } + } + + /** + * @Then the site :site should have :count running containers + */ + public function theSiteShouldHaveRunningContainers( $site, $count ) + { + $result = EE::launch( 'docker ps --format "{{.Names}}" --filter ' . escapeshellarg( "label=io.easyengine.site=$site" ), false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list site containers:\n" . $this->describe_command( $result ) ); + } + $names = array_filter( explode( "\n", trim( $result->stdout ) ) ); + if ( (int) $count !== count( $names ) ) { + throw new Exception( "Expected $count running containers for $site, found " . count( $names ) . ': ' . implode( ', ', $names ) ); + } + } + + /** + * @Then the :service container of site :site should use image :image + */ + public function theContainerOfSiteShouldUseImage( $service, $site, $image ) + { + $actual = $this->inspect_site_container( $site, $service, '{{.Config.Image}}' ); + if ( 0 !== strpos( $actual, $image ) ) { + throw new Exception( "The $service container of $site uses image $actual, expected $image*." ); + } + } + + /** + * @Then the :service container of site :site should have restart policy :policy + */ + public function theContainerOfSiteShouldHaveRestartPolicy( $service, $site, $policy ) + { + $actual = $this->inspect_site_container( $site, $service, '{{.HostConfig.RestartPolicy.Name}}' ); + if ( $policy !== $actual ) { + throw new Exception( "The $service container of $site has restart policy '$actual', expected '$policy'." ); + } + } + + /** + * @Then the :service container of site :site should be connected to network :network + */ + public function theContainerOfSiteShouldBeConnectedToNetwork( $service, $site, $network ) + { + $networks = $this->container_networks( $site, $service ); + if ( ! in_array( $network, $networks, true ) ) { + throw new Exception( "The $service container of $site is not on $network; networks: " . implode( ', ', $networks ) ); + } + } + + /** + * @Then the :service container of site :site should not be connected to network :network + */ + public function theContainerOfSiteShouldNotBeConnectedToNetwork( $service, $site, $network ) + { + $networks = $this->container_networks( $site, $service ); + if ( in_array( $network, $networks, true ) ) { + throw new Exception( "The $service container of $site is connected to $network." ); + } + } + + /** + * @Given I note the start time of the :service container of site :site + */ + public function iNoteTheStartTimeOfTheContainerOfSite( $service, $site ) + { + $this->noted_start_times[ "$site/$service" ] = $this->inspect_site_container( $site, $service, '{{.State.StartedAt}}' ); + } + + /** + * @Then the :service container of site :site should have been restarted + */ + public function theContainerOfSiteShouldHaveBeenRestarted( $service, $site ) + { + if ( ! isset( $this->noted_start_times[ "$site/$service" ] ) ) { + throw new Exception( "No start time was noted for the $service container of $site." ); + } + $now = $this->inspect_site_container( $site, $service, '{{.State.StartedAt}}' ); + if ( $now === $this->noted_start_times[ "$site/$service" ] ) { + throw new Exception( "The $service container of $site was not restarted (started at $now)." ); + } + } + + /** + * @Then the docker network :network should exist + */ + public function theDockerNetworkShouldExist( $network ) + { + if ( 0 !== EE::launch( 'docker network inspect ' . escapeshellarg( $network ), false, true )->return_code ) { + throw new Exception( "Docker network $network does not exist." ); + } + } + + /** + * @Then the docker network :network should not exist + */ + public function theDockerNetworkShouldNotExist( $network ) + { + $result = EE::launch( 'docker network ls --format "{{.Name}}"', false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list Docker networks:\n" . $this->describe_command( $result ) ); + } + $networks = array_filter( explode( "\n", trim( $result->stdout ) ) ); + if ( in_array( $network, $networks, true ) ) { + throw new Exception( "Docker network $network exists." ); + } + } + + /** + * @Then the site :site should have :count docker volumes + */ + public function theSiteShouldHaveDockerVolumes( $site, $count ) + { + $volumes = $this->site_volumes( $site ); + if ( (int) $count !== count( $volumes ) ) { + throw new Exception( "Expected $count volumes labelled for $site, found " . count( $volumes ) . ': ' . implode( ', ', $volumes ) ); + } + } + + /** + * Runs a throwaway MariaDB (the image EE uses) on the host network, bound to one address only. + * + * @Given a MariaDB server :name is listening on :address port :port with root password :password + */ + public function aMariadbServerIsListening( $name, $address, $port, $password ) + { + $this->assert_test_name( $name ); + $address = $this->replace_placeholders( $address ); + EE::launch( 'docker rm -f -v ' . escapeshellarg( $name ), false, true ); + + $versions = json_decode( file_get_contents( EE_ROOT . '/img-versions.json' ), true ); + $command = sprintf( + 'docker run -d --name %s --network host -e MYSQL_ROOT_PASSWORD=%s easyengine/mariadb:%s --port=%d --bind-address=%s', + escapeshellarg( $name ), + escapeshellarg( $password ), + escapeshellarg( $versions['easyengine/mariadb'] ), + (int) $port, + escapeshellarg( $address ) + ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not start $name:\n" . $this->describe_command( $result ) ); + } + + $this->iWaitUntilSucceedsWithin( + sprintf( 'docker exec %s mysql -h%s -P%d -uroot -p%s -e "SELECT 1"', escapeshellarg( $name ), escapeshellarg( $address ), (int) $port, escapeshellarg( $password ) ), + 120 + ); + } + + /** + * @Then the global database should have a database named :name + */ + public function theGlobalDatabaseShouldHaveADatabaseNamed( $name ) + { + if ( ! in_array( $name, $this->global_db_lines( 'SHOW DATABASES' ), true ) ) { + throw new Exception( "The global database has no database named $name." ); + } + } + + /** + * @Then the global database should not have a database named :name + */ + public function theGlobalDatabaseShouldNotHaveADatabaseNamed( $name ) + { + if ( in_array( $name, $this->global_db_lines( 'SHOW DATABASES' ), true ) ) { + throw new Exception( "The global database still has a database named $name." ); + } + } + + /** + * @Then the global database should have a user named :user + */ + public function theGlobalDatabaseShouldHaveAUserNamed( $user ) + { + if ( ! in_array( $user, $this->global_db_lines( 'SELECT User FROM mysql.user' ), true ) ) { + throw new Exception( "The global database has no user named $user." ); + } + } + + /** + * @Then the global database should not have a user named :user + */ + public function theGlobalDatabaseShouldNotHaveAUserNamed( $user ) + { + if ( in_array( $user, $this->global_db_lines( 'SELECT User FROM mysql.user' ), true ) ) { + throw new Exception( "The global database still has a user named $user." ); + } + } + + /** + * @Then the global database :database should have a table named :table + */ + public function theGlobalDatabaseShouldHaveATableNamed( $database, $table ) + { + $tables = $this->global_db_lines( sprintf( 'SHOW TABLES FROM `%s`', str_replace( '`', '', $database ) ) ); + if ( ! in_array( $table, $tables, true ) ) { + throw new Exception( "Database $database has no table $table; tables: " . implode( ', ', $tables ) ); + } + } + + /** + * Site DB users are `-`, so a deleted site's user can only be matched by prefix. + * + * @Then the global database should not have users starting with :prefix + */ + public function theGlobalDatabaseShouldNotHaveUsersStartingWith( $prefix ) + { + $users = array_filter( $this->global_db_lines( 'SELECT User FROM mysql.user' ), function ( $user ) use ( $prefix ) { + return 0 === strpos( $user, $prefix ); + } ); + if ( $users ) { + throw new Exception( "The global database still has users starting with $prefix: " . implode( ', ', $users ) ); + } + } + + /** + * Guards scenarios that inspect what an earlier scenario left behind, so they can't pass when run alone. + * + * @Given the site :site was deleted earlier in this feature + */ + public function theSiteWasDeletedEarlierInThisFeature( $site ) + { + if ( ! in_array( $site, self::$deleted_sites, true ) ) { + throw new Exception( "$site was not deleted by an earlier scenario of this feature; run the whole feature." ); + } + } + + /** + * The DB user of a site is random unless --dbuser was passed, so it is read from the EE database. + * + * @Then the global database should have the user of site :site + */ + public function theGlobalDatabaseShouldHaveTheUserOfSite( $site ) + { + $this->theGlobalDatabaseShouldHaveAUserNamed( $this->site_field( $site, 'db_user' ) ); + } + + /** + * @Given I store the key :key in the global redis + */ + public function iStoreTheKeyInTheGlobalRedis( $key ) + { + $result = EE::launch( sprintf( 'docker exec %s redis-cli SET %s bh-value', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( $key ) ), false, true ); + if ( 0 !== $result->return_code || false === strpos( $result->stdout, 'OK' ) ) { + throw new Exception( "Could not store $key in global redis:\n" . $this->describe_command( $result ) ); + } + } + + /** + * @Then the global redis should have keys matching :pattern + */ + public function theGlobalRedisShouldHaveKeysMatching( $pattern ) + { + if ( ! $this->global_redis_keys( $pattern ) ) { + throw new Exception( "Global redis has no key matching $pattern." ); + } + } + + /** + * @Then the global redis should not have keys matching :pattern + */ + public function theGlobalRedisShouldNotHaveKeysMatching( $pattern ) + { + $keys = $this->global_redis_keys( $pattern ); + if ( $keys ) { + throw new Exception( "Global redis still has keys matching $pattern: " . implode( ', ', array_slice( $keys, 0, 10 ) ) ); + } + } + + /** + * Requests go to 127.0.0.1 (the host part of the URL is resolved there), so no /etc/hosts entry is needed. + * + * @When I send a GET request to :url + */ + public function iSendAGetRequestTo( $url ) + { + $this->http_request( $this->replace_placeholders( $url ), null, true ); + } + + /** + * @Then a GET request to :url should return :code + */ + public function aGetRequestToShouldReturn( $url, $code ) + { + $this->assert_response_code( $this->http_request( $this->replace_placeholders( $url ), null, true ), $code ); + } + + /** + * @Then a GET request to :url with Host :host should return :code + */ + public function aGetRequestToWithHostShouldReturn( $url, $host, $code ) + { + $this->assert_response_code( $this->http_request( $this->replace_placeholders( $url ), $host, false ), $code ); + } + + /** + * @Then a GET request to :url should return :code within :seconds seconds + */ + public function aGetRequestToShouldReturnWithin( $url, $code, $seconds ) + { + $url = $this->replace_placeholders( $url ); + $deadline = time() + (int) $seconds; + do { + $response = $this->http_request( $url, null, true ); + if ( (int) $code === $response['code'] ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + $this->assert_response_code( $response, $code ); + } + + /** + * @Then a GET request to :url should not return :code + */ + public function aGetRequestToShouldNotReturn( $url, $code ) + { + $response = $this->http_request( $this->replace_placeholders( $url ), null, true ); + // Code 0 is a transport error (no HTTP response), never a pass. + if ( 0 === $response['code'] || (int) $code === $response['code'] ) { + throw new Exception( "GET $url returned {$response['code']}.\n" . $response['headers'] ); + } + } + + /** + * Alias updates return before the proxy serves the new name, so poll. + * + * @Then a GET request to :url should not return :code within :seconds seconds + */ + public function aGetRequestToShouldNotReturnWithin( $url, $code, $seconds ) + { + $url = $this->replace_placeholders( $url ); + $deadline = time() + (int) $seconds; + do { + $response = $this->http_request( $url, null, true ); + // Keep polling on a transport error (code 0), e.g. during a proxy reload. + if ( 0 !== $response['code'] && (int) $code !== $response['code'] ) { + return; + } + sleep( 2 ); + } while ( time() < $deadline ); + + throw new Exception( "GET $url still returned {$response['code']} after $seconds seconds.\n" . $response['headers'] ); + } + + /** + * Polls until a response carries the header line, e.g. `X-SRCache-Fetch-Status: HIT`. + * + * @Then a GET request to :url should have header :header within :seconds seconds + */ + public function aGetRequestToShouldHaveHeaderWithin( $url, $header, $seconds ) + { + $url = $this->replace_placeholders( $url ); + $deadline = time() + (int) $seconds; + do { + $response = $this->http_request( $url, null, true ); + if ( $this->headers_contain( $response['headers'], $header ) ) { + return; + } + sleep( 1 ); + } while ( time() < $deadline ); + + throw new Exception( "GET $url never returned header `$header` within $seconds seconds. Last headers:\n" . $response['headers'] ); + } + + /** + * @Then the response should have header :header + */ + public function theResponseShouldHaveHeader( $header ) + { + $response = $this->last_response(); + if ( ! $this->headers_contain( $response['headers'], $header ) ) { + throw new Exception( "Header `$header` not found in:\n" . $response['headers'] ); + } + } + + /** + * @Then the response should not have header :name + */ + public function theResponseShouldNotHaveHeader( $name ) + { + $response = $this->last_response(); + // Code 0 is a transport error (no HTTP response), never a pass. + if ( 0 === $response['code'] ) { + throw new Exception( "No HTTP response to check:\n" . $response['headers'] ); + } + if ( preg_match( '/^' . preg_quote( $name, '/' ) . '\s*:/mi', $response['headers'] ) ) { + throw new Exception( "Header `$name` found in:\n" . $response['headers'] ); + } + } + + /** + * @Then the response should redirect to :location + */ + public function theResponseShouldRedirectTo( $location ) + { + $response = $this->last_response(); + if ( ! in_array( $response['code'], [ 301, 302, 307, 308 ], true ) || ! preg_match( '/^Location:\s*' . preg_quote( $location, '/' ) . '\s*$/mi', $response['headers'] ) ) { + throw new Exception( "Expected a redirect to $location, got:\n" . $response['headers'] ); + } + } + + /** + * @Then the response body should contain :text + */ + public function theResponseBodyShouldContain( $text ) + { + $response = $this->last_response(); + if ( false === strpos( $response['body'], $text ) ) { + throw new Exception( "`$text` not found in the body of {$response['url']}:\n" . substr( $response['body'], 0, 2000 ) ); + } + } + + /** + * @Given I add a hosts entry for :host + */ + public function iAddAHostsEntryFor( $host ) + { + $this->assert_test_name( $host ); + $line = "127.0.0.1\t$host"; + if ( false === file_put_contents( '/etc/hosts', PHP_EOL . $line . PHP_EOL, FILE_APPEND ) ) { + throw new Exception( 'Could not write /etc/hosts.' ); + } + $this->added_hosts_lines[] = $line; + } + + /** + * @Then the hosts file should contain :host + */ + public function theHostsFileShouldContain( $host ) + { + if ( ! preg_match( '/\s' . preg_quote( $host, '/' ) . '$/m', file_get_contents( '/etc/hosts' ) ) ) { + throw new Exception( "/etc/hosts has no entry for $host." ); + } + } + + /** + * @Then the hosts file should not contain :host + */ + public function theHostsFileShouldNotContain( $host ) + { + if ( preg_match( '/\s' . preg_quote( $host, '/' ) . '$/m', file_get_contents( '/etc/hosts' ) ) ) { + throw new Exception( "/etc/hosts still has an entry for $host." ); + } + } + + /* ------------------------------------------------------------------------------------------------------------ + * Helpers. + * --------------------------------------------------------------------------------------------------------- */ + + private function replace_placeholders( $text ) { + $map = [ + '{EE_ROOT_DIR}' => EE_ROOT_DIR, + '{SITES_DIR}' => EE_SITE_ROOT, + '{TMP_DIR}' => self::TEST_TMP_DIR, + '{HOME}' => self::home_dir(), + ]; + if ( false !== strpos( $text, '{DOCKER_BRIDGE_IP}' ) ) { + $map['{DOCKER_BRIDGE_IP}'] = self::docker_bridge_ip(); + } + + return strtr( $text, $map ); + } + + /** + * HOME as ee's shells see it (sudo -E keeps the caller's, e.g. /home/runner on CI). + */ + private static function home_dir() { + $home = getenv( 'HOME' ); + + return $home ? rtrim( $home, '/' ) : '/root'; + } + + private static function docker_bridge_ip() { + $result = EE::launch( "docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'", false, true ); + $ip = trim( $result->stdout ); + if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + throw new Exception( "Could not determine the docker bridge gateway:\n" . $result->stdout . $result->stderr ); + } + + return $ip; + } + + private function last_command() { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command has been run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function assert_last_command_succeeded() { + $result = $this->last_command(); + if ( 0 !== $result->return_code ) { + throw new Exception( "Expected exit code 0.\n" . $this->describe_command( $result ) ); + } + } + + private function describe_command( $result ) { + return sprintf( "Command: %s\nExit code: %s\nSTDOUT:\n%s\nSTDERR:\n%s", $result->command, $result->return_code, $result->stdout, $result->stderr ); + } + + private function stream_output( $stream ) { + $result = $this->last_command(); + if ( 'STDOUT' === $stream ) { + $output = $result->stdout; + } elseif ( 'STDERR' === $stream ) { + $output = $result->stderr; + } else { + $output = $result->stdout . "\n" . $result->stderr; + } + + return str_replace( [ "\033[1;31m", "\033[0m", "\033[32m", "\033[33m", "\033[1;33m", "\033[1;32m" ], '', $output ); + } + + private function assert_output_lines( $haystack, PyStringNode $expected, $should_contain, $label ) { + foreach ( $expected->getStrings() as $line ) { + $line = $this->replace_placeholders( trim( $line ) ); + if ( '' === $line ) { + continue; + } + $found = false !== strpos( $haystack, $line ); + if ( $found !== $should_contain ) { + $context = empty( $this->commands ) ? '' : "\n\n" . $this->describe_command( $this->last_command() ); + throw new Exception( sprintf( "%s %s `%s`. Actual %s:\n%s%s", ucfirst( $label ), $should_contain ? 'does not contain' : 'contains', $line, $label, $haystack, 0 === strpos( $label, 'file' ) ? '' : $context ) ); + } + } + } + + private function read_file( $path ) { + $path = $this->replace_placeholders( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( "File $path does not exist." ); + } + + return file_get_contents( $path ); + } + + private function scalar_to_string( $value ) { + if ( is_bool( $value ) ) { + return $value ? '1' : '0'; + } + + return null === $value ? '' : (string) $value; + } + + private function value_matches( $actual, $expected ) { + if ( '' === $expected ) { + return '' === $actual || '0' === $actual; + } + if ( '' === $expected ) { + return '' !== $actual; + } + if ( strlen( $expected ) > 2 && '/' === $expected[0] && '/' === substr( $expected, -1 ) ) { + return (bool) preg_match( $expected, $actual ); + } + + return $expected === $actual; + } + + private function ee_db() { + $pdo = new PDO( 'sqlite:' . EE_ROOT_DIR . '/db/ee.sqlite' ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + return $pdo; + } + + private function assert_identifier( $name ) { + if ( ! preg_match( '/^[a-z_][a-z0-9_]*$/i', $name ) ) { + throw new Exception( "Invalid SQL identifier: $name" ); + } + } + + private function site_row( $site ) { + // Before core's first non-cli command there is no DB (and so no site); opening it would create an empty one. + if ( ! is_file( EE_ROOT_DIR . '/db/ee.sqlite' ) ) { + return false; + } + $statement = $this->ee_db()->prepare( 'SELECT * FROM sites WHERE site_url = ?' ); + $statement->execute( [ $site ] ); + + return $statement->fetch( PDO::FETCH_ASSOC ); + } + + private function site_field( $site, $field ) { + $row = $this->site_row( $site ); + if ( ! $row || ! array_key_exists( $field, $row ) ) { + throw new Exception( "Site $site or its field $field not found in the EE database." ); + } + + return $this->scalar_to_string( $row[ $field ] ); + } + + private function assert_site_field( $site, $field, $expected ) { + $actual = $this->site_field( $site, $field ); + if ( $expected !== $actual ) { + throw new Exception( "`$field` of $site is '$actual', expected '$expected'." ); + } + } + + private function run_wp_cli( $site, $command ) { + $command = sprintf( + 'cd %s && docker-compose exec -T --user=www-data php sh -c %s', + escapeshellarg( EE_SITE_ROOT . '/' . $site ), + escapeshellarg( 'wp ' . $command ) + ); + $result = EE::launch( $command, false, true ); + $this->commands[] = $result; + + return $result; + } + + private function site_container_id( $site, $service, $include_stopped = true ) { + $command = sprintf( + 'docker ps %s -q --filter %s --filter %s', + $include_stopped ? '-a' : '', + escapeshellarg( "label=io.easyengine.site=$site" ), + escapeshellarg( "label=com.docker.compose.service=$service" ) + ); + $result = EE::launch( $command, false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list site containers:\n" . $this->describe_command( $result ) ); + } + $ids = array_filter( explode( "\n", trim( $result->stdout ) ) ); + + return $ids ? reset( $ids ) : ''; + } + + private function inspect_site_container( $site, $service, $format ) { + $id = $this->site_container_id( $site, $service ); + if ( '' === $id ) { + throw new Exception( "No $service container exists for $site." ); + } + + $result = EE::launch( 'docker inspect -f ' . escapeshellarg( $format ) . " $id", false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not inspect the $service container of $site:\n" . $this->describe_command( $result ) ); + } + + return trim( $result->stdout ); + } + + private function container_networks( $site, $service ) { + $networks = json_decode( $this->inspect_site_container( $site, $service, '{{json .NetworkSettings.Networks}}' ), true ); + + return is_array( $networks ) ? array_keys( $networks ) : []; + } + + private static function site_volumes( $site ) { + $result = EE::launch( 'docker volume ls -q --filter ' . escapeshellarg( "label=io.easyengine.site=$site" ), false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not list site volumes:\n" . $result->stdout . $result->stderr ); + } + + return array_values( array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + } + + private static function global_db_query( $sql ) { + $script = 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B -e ' . escapeshellarg( $sql ); + + return EE::launch( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_DB_CONTAINER, escapeshellarg( $script ) ), false, true ); + } + + private function global_db_lines( $sql ) { + $result = self::global_db_query( $sql ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Global DB query failed:\n" . $this->describe_command( $result ) ); + } + + return array_map( 'trim', array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + } + + private function global_redis_keys( $pattern ) { + $result = EE::launch( sprintf( 'docker exec %s redis-cli --scan --pattern %s', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( $pattern ) ), false, true ); + if ( 0 !== $result->return_code ) { + throw new Exception( "Could not scan global redis:\n" . $this->describe_command( $result ) ); + } + + return array_values( array_filter( explode( "\n", trim( $result->stdout ) ) ) ); + } + + /** + * @param string $url Full URL. With $resolve the URL's host is pinned to 127.0.0.1 (keeps SNI correct). + * @param string|null $host Explicit Host header (for URLs pointing at an IP). + * @param bool $resolve Resolve the URL host to 127.0.0.1. + * @param array $headers Extra raw request headers. + */ + private function http_request( $url, $host = null, $resolve = true, $headers = [] ) { + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $url ); + curl_setopt( $ch, CURLOPT_HEADER, true ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 60 ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 ); + if ( $resolve ) { + $url_host = parse_url( $url, PHP_URL_HOST ); + $port = parse_url( $url, PHP_URL_PORT ) ?: ( 'https' === parse_url( $url, PHP_URL_SCHEME ) ? 443 : 80 ); + curl_setopt( $ch, CURLOPT_RESOLVE, [ "$url_host:$port:127.0.0.1" ] ); + } + if ( null !== $host ) { + $headers[] = 'Host: ' . $host; + } + if ( $headers ) { + curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); + } + $raw = curl_exec( $ch ); + $code = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); + $header_size = (int) curl_getinfo( $ch, CURLINFO_HEADER_SIZE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + $this->response = [ + 'url' => $url, + // Incomplete transfers fail even when a status and headers were received. + 'code' => false === $raw ? 0 : $code, + 'headers' => false === $raw ? "(request failed: $error)" : substr( $raw, 0, $header_size ), + 'body' => false === $raw ? '' : (string) substr( $raw, $header_size ), + ]; + + return $this->response; + } + + private function last_response() { + if ( null === $this->response ) { + throw new Exception( 'No HTTP request has been sent in this scenario.' ); + } + + return $this->response; + } + + private function assert_response_code( $response, $code ) { + if ( (int) $code !== $response['code'] ) { + throw new Exception( "GET {$response['url']} returned {$response['code']}, expected $code.\n" . $response['headers'] . substr( $response['body'], 0, 1000 ) ); + } + } + + private function headers_contain( $headers, $header ) { + return false !== stripos( $headers, $header ); + } + + private function assert_headers_contain_rows( $headers, TableNode $table ) { + foreach ( $table->getHash() as $row ) { + if ( ! $this->headers_contain( $headers, $row['header'] ) ) { + throw new Exception( 'Unable to find ' . $row['header'] . "\nActual output is : " . $headers ); + } + } + } + + private function assert_test_name( $name ) { + if ( false === strpos( $name, self::TEST_PREFIX ) ) { + throw new Exception( "Refusing to touch `$name`: test fixtures must contain " . self::TEST_PREFIX ); + } + } + + private function remove_added_hosts_lines() { + if ( empty( $this->added_hosts_lines ) ) { + return; + } + $lines = file( '/etc/hosts', FILE_IGNORE_NEW_LINES ); + foreach ( $this->added_hosts_lines as $added ) { + $index = array_search( $added, $lines, true ); + if ( false !== $index ) { + unset( $lines[ $index ] ); + } + } + file_put_contents( '/etc/hosts', implode( PHP_EOL, $lines ) . PHP_EOL ); + $this->added_hosts_lines = []; + } + + /** + * Deletes everything this suite may have left behind; each item is matched by the test prefix. + */ + private static function cleanup_test_artifacts() { + $ee = escapeshellarg( realpath( EE_ROOT ) . '/bin/ee' ); + + // Core creates the DB on its first non-cli command; opening a missing SQLite file would create an empty one without the sites table. + $db = EE_ROOT_DIR . '/db/ee.sqlite'; + $sites = is_file( $db ) ? ( new PDO( 'sqlite:' . $db ) )->query( 'SELECT site_url FROM sites' )->fetchAll( PDO::FETCH_COLUMN ) : []; + foreach ( $sites as $site ) { + if ( false !== strpos( $site, self::TEST_PREFIX ) ) { + // --foreground: in a background process group the `docker exec -it` of a cache purge stops on SIGTTOU (with timeout itself) and hangs forever. + EE::launch( "timeout --foreground -k 30 600 $ee site delete " . escapeshellarg( $site ) . ' --yes', false, true ); + } + } + + // Webroots left without a DB row (failed creates, fixtures). + foreach ( glob( EE_SITE_ROOT . '/*' . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $dir ) { + EE::launch( 'rm -rf ' . escapeshellarg( $dir ), false, true ); + } + + // Containers (external DB fixtures and orphans of failed creates), then volumes and networks. + $containers = EE::launch( 'docker ps -a --format "{{.ID}} {{.Names}} {{.Label \"io.easyengine.site\"}}"', false, true )->stdout; + foreach ( array_filter( explode( "\n", trim( $containers ) ) ) as $line ) { + if ( false !== strpos( $line, self::TEST_PREFIX ) || false !== strpos( $line, str_replace( '-', '', self::TEST_PREFIX ) ) ) { + EE::launch( 'docker rm -f -v ' . strtok( $line, ' ' ), false, true ); + } + } + $volumes = EE::launch( 'docker volume ls --filter label=org.label-schema.vendor=EasyEngine --format "{{.Name}} {{.Label \"io.easyengine.site\"}}"', false, true )->stdout; + foreach ( array_filter( explode( "\n", trim( $volumes ) ) ) as $line ) { + if ( false !== strpos( $line, self::TEST_PREFIX ) ) { + EE::launch( 'docker volume rm ' . escapeshellarg( strtok( $line, ' ' ) ), false, true ); + } + } + $networks = EE::launch( 'docker network ls --format "{{.Name}}"', false, true )->stdout; + foreach ( array_filter( explode( "\n", trim( $networks ) ) ) as $network ) { + if ( false !== strpos( $network, self::TEST_PREFIX ) ) { + EE::launch( 'docker network rm ' . escapeshellarg( $network ), false, true ); + } + } + + // Global DB databases and users (also leaked by failed creates). + $databases = self::global_db_query( 'SHOW DATABASES' ); + foreach ( array_filter( explode( "\n", trim( $databases->stdout ) ) ) as $database ) { + if ( false !== strpos( $database, self::TEST_DB_PREFIX ) ) { + self::global_db_query( sprintf( 'DROP DATABASE `%s`', $database ) ); + } + } + $users = self::global_db_query( 'SELECT User FROM mysql.user' ); + foreach ( array_filter( explode( "\n", trim( $users->stdout ) ) ) as $user ) { + if ( false !== strpos( $user, self::TEST_PREFIX ) || false !== strpos( $user, self::TEST_DB_PREFIX ) ) { + self::global_db_query( sprintf( "DROP USER '%s'@'%%'", str_replace( "'", '', $user ) ) ); + } + } + + // Global redis keys written by cache scenarios. + EE::launch( sprintf( 'docker exec %s sh -c %s', self::GLOBAL_REDIS_CONTAINER, escapeshellarg( "redis-cli --scan --pattern '" . self::TEST_PREFIX . "*' | xargs -r redis-cli DEL" ) ), false, true ); + + // Hosts entries (EE's own and the ones added by scenarios). + $hosts = file( '/etc/hosts', FILE_IGNORE_NEW_LINES ); + if ( false !== $hosts ) { + $kept = array_filter( $hosts, function ( $line ) { + return false === strpos( $line, self::TEST_PREFIX ); + } ); + if ( count( $kept ) !== count( $hosts ) ) { + file_put_contents( '/etc/hosts', implode( PHP_EOL, $kept ) . PHP_EOL ); + } + } + + // Proxy certs and ACME state: `--ssl=off` and LE deletes leave them behind. + $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; + foreach ( [ 'certs', 'acme-conf/certs', 'acme-conf/var' ] as $sub ) { + foreach ( glob( "$proxy_dir/$sub/*" . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $path ) { + EE::launch( 'rm -rf ' . escapeshellarg( $path ), false, true ); + } + } + + // Backups written by `ee site update --ssl` search-replace, fixtures and injection markers. + foreach ( glob( EE_ROOT_DIR . '/.backup/*' . self::TEST_PREFIX . '*', GLOB_NOSORT ) ?: [] as $dir ) { + EE::launch( 'rm -rf ' . escapeshellarg( $dir ), false, true ); + } + EE::launch( 'rm -rf ' . escapeshellarg( self::TEST_TMP_DIR ) . ' ' . escapeshellarg( self::home_dir() . '/bhsitetypewpsec1pwned' ) . ' /root/bhsitetypewpsec1pwned', false, true ); + } } diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index ed9a5b5..a5f1a1e 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..6b85905 --- /dev/null +++ b/features/cache.feature @@ -0,0 +1,134 @@ +Feature: WordPress redis cache (--cache, --with-local-redis) + + Scenario: Create a site with page and object cache on the global redis + When I run 'bin/ee site create bh-site-type-wp-cache.test --type=wp --cache' + Then the command should succeed + And the site 'bh-site-type-wp-cache.test' info should contain: + | field | value | + | cache_nginx_browser | 1 | + | cache_nginx_fullpage | 1 | + | cache_mysql_query | 1 | + | cache_app_object | 1 | + | cache_host | global-redis | + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/config/nginx/conf.d/main.conf' should contain: + """ + server global-redis:6379; + # Redis NGINX CONFIGURATION + set $key "bh-site-type-wp-cache.test_page:http$request_method$host$request_uri"; + """ + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/config/nginx/conf.d/main.conf' should not contain: + """ + # PHP NGINX CONFIGURATION + """ + And the 'redis' container of site 'bh-site-type-wp-cache.test' should not exist + And the 'php' container of site 'bh-site-type-wp-cache.test' should be connected to network 'ee-global-backend-network' + And the 'nginx' container of site 'bh-site-type-wp-cache.test' should be connected to network 'ee-global-backend-network' + When I run WP-CLI 'plugin is-active nginx-helper' on site 'bh-site-type-wp-cache.test' + Then the command should succeed + When I run WP-CLI 'plugin is-active wp-redis' on site 'bh-site-type-wp-cache.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/app/htdocs/wp-content/object-cache.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-cache.test/app/wp-config.php' should contain: + """ + $redis_server = array('host'=> 'global-redis','port'=> 6379,); + define( 'RT_WP_NGINX_HELPER_REDIS_HOSTNAME', 'global-redis' ); + define( 'RT_WP_NGINX_HELPER_REDIS_PORT', '6379' ); + define( 'RT_WP_NGINX_HELPER_REDIS_PREFIX', 'bh-site-type-wp-cache.test_page:' ); + define( 'WP_CACHE_KEY_SALT', 'bh-site-type-wp-cache.test_obj:' ); + define( 'WP_REDIS_MAXTTL', '14400' ); + """ + When I run WP-CLI 'option get rt_wp_nginx_helper_options --format=json' on site 'bh-site-type-wp-cache.test' + Then STDOUT should contain: + """ + "redis_hostname":"global-redis" + "redis_prefix":"bh-site-type-wp-cache.test_page:" + "cache_method":"enable_redis" + """ + When I run 'bin/ee site info bh-site-type-wp-cache.test' + Then STDOUT should contain: + """ + Enabled + """ + + Scenario: The page cache is served from redis + Given a WordPress site 'bh-site-type-wp-cache.test' exists created with '--cache' + When I send a GET request to 'http://bh-site-type-wp-cache.test/' + Then the response should have header 'X-SRCache-Fetch-Status' + And a GET request to 'http://bh-site-type-wp-cache.test/' should have header 'X-SRCache-Fetch-Status: HIT' within 20 seconds + And the global redis should have keys matching 'bh-site-type-wp-cache.test_page:*' + # Logged-in cookies and query strings bypass the cache. + When I send a GET request to 'http://bh-site-type-wp-cache.test/?bh=1' + Then the response should have header 'X-SRCache-Fetch-Status: BYPASS' + + # Regression check (fixed in v4.13.2 by EasyEngine/site-command#510): clean_site_cache() runs `docker exec -it`, which fails without a TTY (and can hang under nested ptys), yet `ee site clean` still prints Success. + Scenario: ee site clean purges the page and object cache keys + Given a WordPress site 'bh-site-type-wp-cacheclean.test' exists created with '--cache --skip-install' + And I store the key 'bh-site-type-wp-cacheclean.test_page:httpGET/bh' in the global redis + And I store the key 'bh-site-type-wp-cacheclean.test_obj:bh' in the global redis + When I run 'bin/ee site clean bh-site-type-wp-cacheclean.test --page' + Then the command should succeed + And STDOUT should contain: + """ + Page cache cleared for bh-site-type-wp-cacheclean.test + """ + And the global redis should not have keys matching 'bh-site-type-wp-cacheclean.test_page:*' + And the global redis should have keys matching 'bh-site-type-wp-cacheclean.test_obj:*' + When I run 'bin/ee site clean bh-site-type-wp-cacheclean.test' + Then the command should succeed + And STDOUT should contain: + """ + Page cache cleared for bh-site-type-wp-cacheclean.test + Object cache cleared for bh-site-type-wp-cacheclean.test + """ + And the global redis should not have keys matching 'bh-site-type-wp-cacheclean.test_obj:*' + + Scenario: Cache clean without proxy cache rejects --proxy + Given a WordPress site 'bh-site-type-wp-cache.test' exists created with '--cache' + When I run 'bin/ee site clean bh-site-type-wp-cache.test --proxy' + Then the command should fail + And STDERR should contain: + """ + Proxy cache is not enabled on site. + """ + + Scenario: Create a site with cache on a local redis container + When I run 'bin/ee site create bh-site-type-wp-lredis.test --type=wp --cache --with-local-redis' + Then the command should succeed + And the site 'bh-site-type-wp-lredis.test' info should contain: + | field | value | + | cache_nginx_fullpage | 1 | + | cache_host | redis | + And the site 'bh-site-type-wp-lredis.test' should have 4 running containers + And the 'redis' container of site 'bh-site-type-wp-lredis.test' should be running + And the 'redis' container of site 'bh-site-type-wp-lredis.test' should use image 'easyengine/redis:' + And the 'nginx' container of site 'bh-site-type-wp-lredis.test' should not be connected to network 'ee-global-backend-network' + And the file '{SITES_DIR}/bh-site-type-wp-lredis.test/config/nginx/conf.d/main.conf' should contain: + """ + server redis:6379; + """ + And the file '{SITES_DIR}/bh-site-type-wp-lredis.test/docker-compose.yml' should contain: + """ + image: easyengine/redis: + - redis + """ + And the file '{SITES_DIR}/bh-site-type-wp-lredis.test/app/wp-config.php' should contain: + """ + define( 'RT_WP_NGINX_HELPER_REDIS_HOSTNAME', 'redis' ); + $redis_server = array('host'=> 'redis','port'=> 6379,); + """ + And a GET request to 'http://bh-site-type-wp-lredis.test/' should have header 'X-SRCache-Fetch-Status: HIT' within 20 seconds + And the global redis should not have keys matching 'bh-site-type-wp-lredis.test_page:*' + When I run 'bin/ee site delete bh-site-type-wp-lredis.test --yes' + Then the command should succeed + And the 'redis' container of site 'bh-site-type-wp-lredis.test' should not exist + + Scenario: Subdomain multisite with cache stores the nginx-helper options as network meta + When I run 'bin/ee site create bh-site-type-wp-mucache.test --type=wp --mu=subdom --cache' + Then the command should succeed + When I run WP-CLI 'network meta get 1 rt_wp_nginx_helper_options --format=json' on site 'bh-site-type-wp-mucache.test' + Then the command should succeed + And STDOUT should contain: + """ + "redis_hostname":"global-redis" + """ + And a GET request to 'http://bh-site-type-wp-mucache.test/' should return 200 diff --git a/features/database.feature b/features/database.feature new file mode 100644 index 0000000..bbb973a --- /dev/null +++ b/features/database.feature @@ -0,0 +1,184 @@ +Feature: Database options of WordPress sites (global DB flags, --local-db) + + Scenario: Custom database name, user, password, prefix, charset and collation on the global DB + When I run 'bin/ee site create bh-site-type-wp-dbopts.test --type=wp --dbname=bh_site_type_wp_customdb --dbuser=bh_site_type_wp_customuser --dbpass=BhDbPass123 --dbprefix=bhp_ --dbcharset=utf8 --dbcollate=utf8_general_ci --skip-check' + Then the command should succeed + And the site 'bh-site-type-wp-dbopts.test' info should contain: + | field | value | + | db_host | global-db | + | db_name | bh_site_type_wp_customdb | + | db_user | bh_site_type_wp_customuser | + | db_password | BhDbPass123 | + And the global database should have a database named 'bh_site_type_wp_customdb' + And the global database should have a user named 'bh_site_type_wp_customuser' + And the global database 'bh_site_type_wp_customdb' should have a table named 'bhp_options' + And the global database should not have a database named 'bh_site_type_wp_dbopts_test' + And the file '{SITES_DIR}/bh-site-type-wp-dbopts.test/.env' should contain: + """ + WORDPRESS_DB_USER=bh_site_type_wp_customuser + WORDPRESS_DB_NAME=bh_site_type_wp_customdb + WORDPRESS_DB_PASSWORD=BhDbPass123 + """ + When I run WP-CLI 'config get table_prefix' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should contain: + """ + bhp_ + """ + When I run WP-CLI 'config get DB_CHARSET' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should match '/^utf8$/m' + When I run WP-CLI 'config get DB_COLLATE' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should contain: + """ + utf8_general_ci + """ + When I run WP-CLI 'config get DB_USER' on site 'bh-site-type-wp-dbopts.test' + Then STDOUT should contain: + """ + bh_site_type_wp_customuser + """ + And a GET request to 'http://bh-site-type-wp-dbopts.test/' should return 200 + + Scenario: Deleting the site drops its custom database and user + Given a WordPress site 'bh-site-type-wp-dbopts.test' exists created with '--dbname=bh_site_type_wp_customdb --dbuser=bh_site_type_wp_customuser --dbpass=BhDbPass123' + When I run 'bin/ee site delete bh-site-type-wp-dbopts.test --yes' + Then the command should succeed + And the global database should not have a database named 'bh_site_type_wp_customdb' + And the global database should not have a user named 'bh_site_type_wp_customuser' + + Scenario: Create a site with a local database container + When I run 'bin/ee site create bh-site-type-wp-localdb.test --type=wp --local-db' + Then the command should succeed + And the site 'bh-site-type-wp-localdb.test' info should contain: + | field | value | + | db_host | db | + | db_name | bh_site_type_wp_localdb_test | + | db_root_password | | + And the 'db' container of site 'bh-site-type-wp-localdb.test' should be running + And the 'db' container of site 'bh-site-type-wp-localdb.test' should use image 'easyengine/mariadb:' + And the 'db' container of site 'bh-site-type-wp-localdb.test' should have restart policy 'always' + And the site 'bh-site-type-wp-localdb.test' should have 4 running containers + And the site 'bh-site-type-wp-localdb.test' should have 11 docker volumes + And the 'php' container of site 'bh-site-type-wp-localdb.test' should not be connected to network 'ee-global-backend-network' + And the global database should not have a database named 'bh_site_type_wp_localdb_test' + And the file '{SITES_DIR}/bh-site-type-wp-localdb.test/.env' should contain: + """ + MYSQL_ROOT_PASSWORD= + MYSQL_DATABASE=bh_site_type_wp_localdb_test + WORDPRESS_DB_HOST=db + """ + And the file '{SITES_DIR}/bh-site-type-wp-localdb.test/docker-compose.yml' should contain: + """ + image: easyengine/mariadb: + - db + """ + When I run WP-CLI 'config get DB_HOST' on site 'bh-site-type-wp-localdb.test' + Then STDOUT should contain: + """ + db:3306 + """ + When I run 'bin/ee site info bh-site-type-wp-localdb.test' + Then STDOUT should contain: + """ + DB Root Password + """ + And a GET request to 'http://bh-site-type-wp-localdb.test/' should return 200 + + Scenario: Restart of a local-db site includes the db container + Given a WordPress site 'bh-site-type-wp-localdb.test' exists created with '--local-db' + And I note the start time of the 'db' container of site 'bh-site-type-wp-localdb.test' + When I run 'bin/ee site restart bh-site-type-wp-localdb.test --db' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should have been restarted + And a GET request to 'http://bh-site-type-wp-localdb.test/' should return 200 within 90 seconds + + Scenario: Disable and enable keep the local database + Given a WordPress site 'bh-site-type-wp-localdb.test' exists created with '--local-db' + When I run 'bin/ee site disable bh-site-type-wp-localdb.test' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should not exist + When I run 'bin/ee site enable bh-site-type-wp-localdb.test' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should be running + And a GET request to 'http://bh-site-type-wp-localdb.test/' should return 200 within 90 seconds + + Scenario: Deleting a local-db site removes the db container and volumes + Given a WordPress site 'bh-site-type-wp-localdb.test' exists created with '--local-db' + When I run 'bin/ee site delete bh-site-type-wp-localdb.test --yes' + Then the command should succeed + And the 'db' container of site 'bh-site-type-wp-localdb.test' should not exist + And the site 'bh-site-type-wp-localdb.test' should have 0 docker volumes + + Scenario: Remote database host requires credentials + When I run 'bin/ee site create bh-site-type-wp-remote.test --type=wp --dbhost=203.0.113.10' + Then the command should fail + And STDERR should contain: + """ + `--dbuser` and `--dbpass` are required for remote db host. + """ + And the site 'bh-site-type-wp-remote.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-remote.test' should not exist + + Scenario: Unreachable remote database rolls the site back + When I run 'bin/ee site create bh-site-type-wp-remote.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:1 --dbuser=bh --dbpass=bh' + Then the command should fail + And STDERR should contain: + """ + Unable to connect to remote db + Initiating clean-up. + """ + And the site 'bh-site-type-wp-remote.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-remote.test' should not exist + + # `a-b.test` and `a.b.test` both map to the default name `a_b_test` (and to the same volumes). The create is refused before the database step, and a failed create only drops the database and user it created itself. + Scenario: A database name collision does not drop the other site's database + Given a WordPress site 'bh-site-type-wp-db-c.test' exists created with '--skip-install' + And the global database should have a database named 'bh_site_type_wp_db_c_test' + When I run 'bin/ee site create bh-site-type-wp-db.c.test --type=wp --skip-install' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-db.c.test would share docker volumes (bhsitetypewpdbctest_*) with the existing site bh-site-type-wp-db-c.test. + """ + And the site 'bh-site-type-wp-db.c.test' should not exist + And the global database should have a database named 'bh_site_type_wp_db_c_test' + + # --dbuser of another site is refused up front instead of being dropped by the rollback. + Scenario: A database user collision does not drop the other site's user + Given a WordPress site 'bh-site-type-wp-dbuser.test' exists created with '--dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' + When I run 'bin/ee site create bh-site-type-wp-dbuser2.test --type=wp --dbuser=bh_site_type_wp_shareduser --dbpass=BhDbPass123 --skip-install' + Then the command should fail + And STDERR should contain: + """ + Database user `bh_site_type_wp_shareduser` is already used by site bh-site-type-wp-dbuser.test on the global database. + """ + And the site 'bh-site-type-wp-dbuser2.test' should not exist + And the global database should have a user named 'bh_site_type_wp_shareduser' + And the global database should have a database named 'bh_site_type_wp_dbuser_test' + + # The webroot check runs before the database step, so nothing is created. + Scenario: An existing webroot aborts the create without leaking database objects + Given I run 'mkdir -p {SITES_DIR}/bh-site-type-wp-stale.test' + And I run 'touch {SITES_DIR}/bh-site-type-wp-stale.test/keep-me' + When I run 'bin/ee site create bh-site-type-wp-stale.test --type=wp' + Then the command should fail + And STDERR should contain: + """ + Webroot directory for site bh-site-type-wp-stale.test already exists. + """ + And the file '{SITES_DIR}/bh-site-type-wp-stale.test/keep-me' should exist + And the site 'bh-site-type-wp-stale.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_stale_test' + And the global database should not have users starting with 'bh-site-type-wp-stale.test-' + + # A failed --ssl=custom validation rolls back before any DB work and must not drop the colliding site's default-named database. + Scenario: A failed custom-SSL create does not drop another site's database + Given a WordPress site 'bh-site-type-wp-db-s.test' exists created with '--skip-install' + And the global database should have a database named 'bh_site_type_wp_db_s_test' + When I run 'bin/ee site create bh-site-type-wp-db.s.test --type=wp --ssl=custom --ssl-key=/nonexistent/bh.key --ssl-crt=/nonexistent/bh.crt' + Then the command should fail + And STDERR should contain: + """ + ssl-key OR ssl-crt path does not exist + """ + And the site 'bh-site-type-wp-db.s.test' should not exist + And the global database should have a database named 'bh_site_type_wp_db_s_test' diff --git a/features/external-db.feature b/features/external-db.feature new file mode 100644 index 0000000..ffddf96 --- /dev/null +++ b/features/external-db.feature @@ -0,0 +1,89 @@ +# Uses a throwaway MariaDB on the host network, bound only to the docker bridge gateway ({DOCKER_BRIDGE_IP}), so that +# both EE's verification container (default bridge) and the site's php container can reach it without publishing a port. +# Requires the host to accept connections from docker networks to that address. +@external-db +Feature: WordPress site on a remote database (--dbhost, --force) + + Scenario: Create a site on a remote database that does not exist yet + Given a MariaDB server 'bh-site-type-wp-extdb' is listening on '{DOCKER_BRIDGE_IP}' port 33306 with root password 'BhRootPass123' + When I run 'bin/ee site create bh-site-type-wp-ext.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=BhRootPass123 --dbname=bh_site_type_wp_ext' + Then the command should succeed + And STDOUT should contain: + """ + Verifying connection to remote database + Success: Connection to remote db verified + Database `bh_site_type_wp_ext` does not exist. Attempting to create it. + """ + And the site 'bh-site-type-wp-ext.test' info should contain: + | field | value | + | db_host | {DOCKER_BRIDGE_IP} | + | db_port | 33306 | + | db_name | bh_site_type_wp_ext | + | db_user | root | + | db_root_password | | + And the 'db' container of site 'bh-site-type-wp-ext.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_ext' + And the file '{SITES_DIR}/bh-site-type-wp-ext.test/.env' should contain: + """ + WORDPRESS_DB_HOST={DOCKER_BRIDGE_IP}:33306 + """ + When I run WP-CLI 'config get DB_HOST' on site 'bh-site-type-wp-ext.test' + Then STDOUT should contain: + """ + {DOCKER_BRIDGE_IP}:33306 + """ + And a GET request to 'http://bh-site-type-wp-ext.test/' should return 200 + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext"' + Then STDOUT should contain: + """ + wp_options + """ + # Remote databases are never dropped by EE. + When I run 'bin/ee site delete bh-site-type-wp-ext.test --yes' + Then the command should succeed + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext"' + Then STDOUT should contain: + """ + wp_options + """ + + Scenario: A remote database that already has tables needs --force + Given a MariaDB server 'bh-site-type-wp-extdb' is listening on '{DOCKER_BRIDGE_IP}' port 33306 with root password 'BhRootPass123' + And In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -e "CREATE DATABASE bh_site_type_wp_ext2; CREATE TABLE bh_site_type_wp_ext2.leftover (id INT)"' + When I run 'bin/ee site create bh-site-type-wp-ext2.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=BhRootPass123 --dbname=bh_site_type_wp_ext2' + Then the command should fail + And STDERR should contain: + """ + Some database tables seem to exist in database bh_site_type_wp_ext2. Please backup and reset the database or use `--force` in the site create command to reset it. + Initiating clean-up. + """ + And the site 'bh-site-type-wp-ext2.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-ext2.test' should not exist + # The rollback must not touch the remote database. + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext2"' + Then STDOUT should contain: + """ + leftover + """ + When I run 'bin/ee site create bh-site-type-wp-ext2.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=BhRootPass123 --dbname=bh_site_type_wp_ext2 --force' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-ext2.test/' should return 200 + When In the docker container 'bh-site-type-wp-extdb' I run 'mysql -h{DOCKER_BRIDGE_IP} -P33306 -uroot -pBhRootPass123 -N -e "SHOW TABLES FROM bh_site_type_wp_ext2"' + Then STDOUT should contain: + """ + wp_options + """ + And STDOUT should not contain: + """ + leftover + """ + + Scenario: Wrong remote credentials roll the site back + Given a MariaDB server 'bh-site-type-wp-extdb' is listening on '{DOCKER_BRIDGE_IP}' port 33306 with root password 'BhRootPass123' + When I run 'bin/ee site create bh-site-type-wp-ext3.test --type=wp --dbhost={DOCKER_BRIDGE_IP}:33306 --dbuser=root --dbpass=WrongPass123' + Then the command should fail + And STDERR should contain: + """ + Unable to connect to remote db + """ + And the site 'bh-site-type-wp-ext3.test' should not exist diff --git a/features/install-options.feature b/features/install-options.feature new file mode 100644 index 0000000..07d558d --- /dev/null +++ b/features/install-options.feature @@ -0,0 +1,215 @@ +Feature: WordPress install options (title/admin flags, locale, version, skip-content, skip-install, public-dir, env) + + Scenario: Custom title and administrator, production environment, URL-style site name + When I run "bin/ee site create http://BH-SITE-TYPE-WP-Install.test/ --type=wp --title='BH Test Site' --admin-user=bhadmin --admin-email=BH.Admin@Example.test --admin-pass=BhPass1234 --env=production" + Then the command should succeed + And the site 'bh-site-type-wp-install.test' should exist + And the site 'bh-site-type-wp-install.test' info should contain: + | field | value | + | app_admin_url | BH Test Site | + | app_admin_username | bhadmin | + | app_admin_password | BhPass1234 | + | app_admin_email | BH.Admin@Example.test | + | mailhog_enabled | 0 | + When I run WP-CLI 'option get blogname' on site 'bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + BH Test Site + """ + When I run WP-CLI 'user get bhadmin --field=user_email' on site 'bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + BH.Admin@Example.test + """ + When I run WP-CLI 'user check-password bhadmin BhPass1234' on site 'bh-site-type-wp-install.test' + Then the command should succeed + When I run WP-CLI 'config get WP_ENVIRONMENT_TYPE' on site 'bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + production + """ + And the 'mailhog' container of site 'bh-site-type-wp-install.test' should not exist + When I run 'bin/ee site info bh-site-type-wp-install.test' + Then STDOUT should contain: + """ + BH Test Site + bhadmin + BhPass1234 + """ + + # The message no longer echoes the password (EasyEngine/site-type-wp#244, v4.13.2). + Scenario Outline: Weak administrator password is rejected before anything is created + When I run 'bin/ee site create bh-site-type-wp-weakpass.test --type=wp --admin-pass=' + Then the command should fail + And STDERR should contain: + """ + Issues found in the admin password: + """ + And STDERR should contain: + """ + + """ + And STDERR should not contain: + """ + + """ + And the site 'bh-site-type-wp-weakpass.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_weakpass_test' + + Examples: + | pass | message | + | abc1 | Password too short! Must be at least 8 characters long. | + | 12345678 | Password must include at least one letter! | + | abcdefgh | Password must include at least one number! | + + Scenario: Insecure password allowed, status check skipped, default admin e-mail from --wp-mail + When I run 'bin/ee site create bh-site-type-wp-insecure.test --type=wp --admin-user=bhweak --admin-pass=abc --allow-insecure-pass --skip-status-check --wp-mail=Owner@Example.Test' + Then the command should succeed + And STDOUT should not contain: + """ + Checking and verifying site-up status. + """ + And the site 'bh-site-type-wp-insecure.test' info should contain: + | field | value | + | app_admin_password | abc | + | app_admin_email | owner@example.test | + When I run WP-CLI 'user check-password bhweak abc' on site 'bh-site-type-wp-insecure.test' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-insecure.test/' should return 200 within 60 seconds + + Scenario: EasyEngine v3 flags create a cached WordPress site + When I run 'bin/ee site create bh-site-type-wp-ee3.test --wpredis --user=bhee3 --pass=BhEe3pass1 --email=ee3@example.test' + Then the command should succeed + And the site 'bh-site-type-wp-ee3.test' info should contain: + | field | value | + | site_type | wp | + | cache_nginx_fullpage | 1 | + | cache_host | global-redis | + | app_admin_username | bhee3 | + | app_admin_password | BhEe3pass1 | + | app_admin_email | ee3@example.test | + + Scenario: Removed EasyEngine v3 cache flags are rejected + When I run 'bin/ee site create bh-site-type-wp-w3tc.test --type=wp --w3tc' + Then the command should fail + # The dedicated "no longer supported in EE v4" message is unreachable. + And STDERR should contain: + """ + unknown --w3tc parameter + """ + And the site 'bh-site-type-wp-w3tc.test' should not exist + + Scenario: Localized install on a staging server enables mailhog + When I run 'bin/ee site create bh-site-type-wp-locale.test --type=wp --locale=de_DE --env=staging' + Then the command should succeed + And STDOUT should contain: + """ + Enabling mailhog as this is a staging server. + """ + And the file '{SITES_DIR}/bh-site-type-wp-locale.test/app/htdocs/wp-includes/version.php' should contain: + """ + $wp_local_package = 'de_DE'; + """ + And a GET request to 'http://bh-site-type-wp-locale.test/' should return 200 + # The translation's html_lang_attribute decides between de and de-DE. + And the response body should contain 'lang="de' + And the 'mailhog' container of site 'bh-site-type-wp-locale.test' should be running + And the site 'bh-site-type-wp-locale.test' info should contain: + | field | value | + | mailhog_enabled | 1 | + When I run WP-CLI 'config get WP_ENVIRONMENT_TYPE' on site 'bh-site-type-wp-locale.test' + Then STDOUT should contain: + """ + staging + """ + + Scenario: Install a specific WordPress version + When I run 'bin/ee site create bh-site-type-wp-version.test --type=wp --version=6.8.3' + Then the command should succeed + When I run WP-CLI 'core version' on site 'bh-site-type-wp-version.test' + Then STDOUT should contain: + """ + 6.8.3 + """ + When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-version.test' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-version.test/' should return 200 + + Scenario: Install WordPress without the default themes and plugins + When I run 'bin/ee site create bh-site-type-wp-nocontent.test --type=wp --skip-content' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-nocontent.test/app/htdocs/wp-load.php' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-nocontent.test/app/htdocs/wp-content/plugins/akismet' should not exist + When I run WP-CLI 'theme list --format=count' on site 'bh-site-type-wp-nocontent.test' + Then STDOUT should contain: + """ + 0 + """ + When I run WP-CLI 'plugin list --format=count' on site 'bh-site-type-wp-nocontent.test' + Then STDOUT should contain: + """ + 0 + """ + + Scenario: Skip the WordPress install (site name without TLD gets .test) + When I run 'bin/ee site create bh-site-type-wp-skipinstall --type=wp --skip-install' + Then the command should succeed + And STDOUT should not contain: + """ + Installing WordPress site. + Checking and verifying site-up status. + """ + And the site 'bh-site-type-wp-skipinstall.test' should exist + And the site 'bh-site-type-wp-skipinstall.test' info should contain: + | field | value | + | app_admin_username | | + | app_admin_password | | + And The site 'bh-site-type-wp-skipinstall.test' should have WordPress + And the file '{SITES_DIR}/bh-site-type-wp-skipinstall.test/app/htdocs/wp-load.php' should exist + And the hosts file should not contain 'bh-site-type-wp-skipinstall.test' + And the EE database table 'cron' should have 1 rows where 'site_url' is 'bh-site-type-wp-skipinstall.test' + When I run WP-CLI 'core is-installed' on site 'bh-site-type-wp-skipinstall.test' + Then the command should fail + When I send a GET request to 'http://bh-site-type-wp-skipinstall.test/' + Then the response should have header 'wp-admin/install.php' + When I run 'bin/ee site info bh-site-type-wp-skipinstall.test' + Then STDOUT should not contain: + """ + WordPress Username + """ + + Scenario: Serve WordPress from a public directory inside htdocs + When I run 'bin/ee site create bh-site-type-wp-pubdir.test --type=wp --public-dir=current' + Then the command should succeed + And the site 'bh-site-type-wp-pubdir.test' info should contain: + | field | value | + | site_container_fs_path | /var/www/htdocs/current | + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/app/htdocs/current/wp-load.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/app/htdocs/wp-config.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/app/htdocs/wp-cli.yml' should contain: + """ + path: current/ + """ + And the file '{SITES_DIR}/bh-site-type-wp-pubdir.test/config/nginx/conf.d/main.conf' should contain: + """ + root /var/www/htdocs/current; + """ + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-pubdir.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-pubdir.test + """ + And a GET request to 'http://bh-site-type-wp-pubdir.test/' should return 200 + + # Regression check (fixed in v4.13.1 by EasyEngine/site-type-wp#241): WP-CLI 2.12 corrupts WordPress 7.x downloads (PAX long names are truncated), so the latest version fails its checksums. + Scenario: The latest WordPress download passes its checksums + Given a WordPress site 'bh-site-type-wp-wp1.test' exists created with '--skip-install' + When I run WP-CLI 'core verify-checksums' on site 'bh-site-type-wp-wp1.test' + Then the command should succeed + + # Regression check (fixed in v4.13.2 by EasyEngine/site-type-wp#243 and EasyEngine/site-command#509): shell metacharacters in the site name reach unescaped shell sinks during create. + # The payload survives lowercasing and the docker-prefix stripping of `.`/`-`: it only creates an empty marker in $HOME. + Scenario: Shell metacharacters in the site name are rejected + When I run "bin/ee site create 'bh-site-type-wp-sec1$(cd;>bhsitetypewpsec1pwned).test' --type=wp --skip-install" + Then the command should fail + And the file '{HOME}/bhsitetypewpsec1pwned' should not exist diff --git a/features/labels.feature b/features/labels.feature index f4cacf9..fa11248 100644 --- a/features/labels.feature +++ b/features/labels.feature @@ -1,9 +1,27 @@ -Feature: Container Labels +Feature: Container, volume and network labels of WordPress sites - - Scenario: All easyengine containers are tagged - Given I run "bin/ee site create labels.test --type=wp" - Then There should be 5 containers with labels + Scenario: All easyengine containers, volumes and the site network are tagged + Given a WordPress site 'bh-site-type-wp-labels.test' exists created with '--skip-install' + Then There should be 3 containers with labels """ - io.easyengine.site=labels.test + io.easyengine.site=bh-site-type-wp-labels.test """ + And the site 'bh-site-type-wp-labels.test' should have 8 docker volumes + When I run 'docker network inspect -f "{{json .Labels}}" bh-site-type-wp-labels.test' + Then STDOUT should contain: + """ + "io.easyengine.site":"bh-site-type-wp-labels.test" + "org.label-schema.vendor":"EasyEngine" + """ + When I run 'docker volume ls --filter label=io.easyengine.site=bh-site-type-wp-labels.test --filter label=org.label-schema.vendor=EasyEngine --format "{{.Name}}"' + Then STDOUT should contain: + """ + bhsitetypewplabelstest_htdocs + bhsitetypewplabelstest_config_nginx + bhsitetypewplabelstest_config_php + bhsitetypewplabelstest_log_nginx + bhsitetypewplabelstest_log_php + bhsitetypewplabelstest_data_postfix + bhsitetypewplabelstest_ssl_postfix + bhsitetypewplabelstest_config_postfix + """ diff --git a/features/multisite.feature b/features/multisite.feature new file mode 100644 index 0000000..23d1b03 --- /dev/null +++ b/features/multisite.feature @@ -0,0 +1,108 @@ +Feature: WordPress multisite (--mu=subdir|subdom) + + Scenario: Create a subdirectory multisite + When I run 'bin/ee site create bh-site-type-wp-subdir.test --type=wp --mu=subdir' + Then the command should succeed + And the site 'bh-site-type-wp-subdir.test' info should contain: + | field | value | + | app_sub_type | subdir | + | site_ssl_wildcard | 0 | + | alias_domains | bh-site-type-wp-subdir.test | + And The site 'bh-site-type-wp-subdir.test' should be 'subdir' multisite + And the file '{SITES_DIR}/bh-site-type-wp-subdir.test/config/nginx/conf.d/main.conf' should contain: + """ + # WPSUBDIRECTORY NGINX CONFIGURATION + rewrite ^(/[^/]+)?(/wp-.*) $2 last; + server_name bh-site-type-wp-subdir.test; + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdir.test/app/wp-config.php' should contain: + """ + define( 'COOKIE_DOMAIN', false ); + """ + When I run WP-CLI 'config get MULTISITE' on site 'bh-site-type-wp-subdir.test' + Then STDOUT should match '/^1$/m' + When I create subsite 'bhsub1' in 'bh-site-type-wp-subdir.test' + Then a GET request to 'http://bh-site-type-wp-subdir.test/' should return 200 + And a GET request to 'http://bh-site-type-wp-subdir.test/bhsub1/' should return 200 + And a GET request to 'http://bh-site-type-wp-subdir.test/bhsub1/wp-login.php' should return 200 + And a GET request to 'http://bh-site-type-wp-subdir.test/bhsub1/wp-admin' should return 301 + When I run WP-CLI 'site list --field=url' on site 'bh-site-type-wp-subdir.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-subdir.test/bhsub1/ + """ + + Scenario: Create a subdomain multisite + When I run 'bin/ee site create bh-site-type-wp-subdom.test --type=wp --mu=subdom' + Then the command should succeed + # The implicit wildcard alias marks the site as wildcard even without SSL. + And the site 'bh-site-type-wp-subdom.test' info should contain: + | field | value | + | app_sub_type | subdom | + | site_ssl_wildcard | 1 | + | alias_domains | bh-site-type-wp-subdom.test,*.bh-site-type-wp-subdom.test | + And The site 'bh-site-type-wp-subdom.test' should be 'subdomain' multisite + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/docker-compose.yml' should contain: + """ + VIRTUAL_HOST=${VIRTUAL_HOST},*.${VIRTUAL_HOST} + CERT_NAME=${VIRTUAL_HOST} + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/config/nginx/conf.d/main.conf' should contain: + """ + server_name bh-site-type-wp-subdom.test *.bh-site-type-wp-subdom.test; + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/config/nginx/conf.d/main.conf' should not contain: + """ + # WPSUBDIRECTORY NGINX CONFIGURATION + """ + And the file '{SITES_DIR}/bh-site-type-wp-subdom.test/app/wp-config.php' should contain: + """ + define( 'COOKIE_DOMAIN', false ); + """ + When I create subsite 'bhsub1' in 'bh-site-type-wp-subdom.test' + Then a GET request to 'http://bh-site-type-wp-subdom.test/' should return 200 + And a GET request to 'http://bhsub1.bh-site-type-wp-subdom.test/' should return 200 within 30 seconds + And the response body should contain 'bhsub1.bh-site-type-wp-subdom.test' + When I run 'bin/ee site info bh-site-type-wp-subdom.test' + Then STDOUT should contain: + """ + *.bh-site-type-wp-subdom.test + """ + + Scenario: Unknown multisite type is rejected before anything is created + When I run 'bin/ee site create bh-site-type-wp-mubad.test --type=wp --mu=subfoo' + Then the command should fail + And STDERR should contain: + """ + Unrecognized multi-site parameter: subfoo. Only `--mu=subdom` and `--mu=subdir` are supported. + """ + And the site 'bh-site-type-wp-mubad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-mubad.test' should not exist + + Scenario: Subdomain multisite cannot inherit SSL + When I run 'bin/ee site create bh-site-type-wp-muinh.test --type=wp --mu=subdom --ssl=inherit' + Then the command should fail + And STDERR should contain: + """ + --wildcard or --mu=subdom flag can not be passed together with --ssl=inherit flag. + """ + And the site 'bh-site-type-wp-muinh.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-muinh.test' should not exist + + # The inherit/wildcard check runs before the database step, so nothing is created. + Scenario: A rejected inherit + subdom create leaves nothing in the global database + When I run 'bin/ee site create bh-site-type-wp-muinh2.test --type=wp --mu=subdom --ssl=inherit' + Then the command should fail + And the global database should not have a database named 'bh_site_type_wp_muinh2_test' + And the global database should not have users starting with 'bh-site-type-wp-muinh2.test-' + + # Regression check: subdom create didn't check its implicit *.site against existing aliases (fixed by EasyEngine/site-type-wp#239). + Scenario: Subdomain multisite whose wildcard is already an alias of another site is rejected + Given a WordPress site 'bh-site-type-wp-auth5owner.test' exists created with "--skip-install --alias-domains='*.bh-site-type-wp-auth5.test'" + When I run 'bin/ee site create bh-site-type-wp-auth5.test --type=wp --mu=subdom --skip-install' + Then the command should fail + And STDERR should contain: + """ + is already an alias domain of site bh-site-type-wp-auth5owner.test + """ + And the site 'bh-site-type-wp-auth5.test' should not exist diff --git a/features/php-version.feature b/features/php-version.feature new file mode 100644 index 0000000..0e04b05 --- /dev/null +++ b/features/php-version.feature @@ -0,0 +1,124 @@ +Feature: PHP versions of WordPress sites (--php on create and update) + + Scenario: Default PHP version and update to another version + Given a WordPress site 'bh-site-type-wp-php.test' exists + Then the site 'bh-site-type-wp-php.test' info should contain: + | field | value | + | php_version | 8.4 | + And the 'php' container of site 'bh-site-type-wp-php.test' should use image 'easyengine/php8.4:' + When I run 'bin/ee site update bh-site-type-wp-php.test --php=8.2' + Then the command should succeed + And STDOUT should contain: + """ + Starting php version update for: bh-site-type-wp-php.test + Taking backup of old php config. + Re-applying previous custom.ini and easyengine.conf changes. + Success: Updated site bh-site-type-wp-php.test to PHP version: 8.2 + """ + And the site 'bh-site-type-wp-php.test' info should contain: + | field | value | + | php_version | 8.2 | + And the 'php' container of site 'bh-site-type-wp-php.test' should use image 'easyengine/php8.2:' + And the directory '{SITES_DIR}/bh-site-type-wp-php.test/.backup/config/php-8.4' should exist + And the file '{SITES_DIR}/bh-site-type-wp-php.test/config/php/php/conf.d/custom.ini' should contain: + """ + sendmail_path = /usr/bin/msmtp -t + """ + When In the 'php' container of site 'bh-site-type-wp-php.test' I run 'php -v' + Then STDOUT should contain: + """ + PHP 8.2. + """ + And a GET request to 'http://bh-site-type-wp-php.test/' should return 200 within 60 seconds + + Scenario: Updating to the current PHP version fails + Given a WordPress site 'bh-site-type-wp-php.test' exists created with '--php=8.2' + When I run 'bin/ee site update bh-site-type-wp-php.test --php=8.2' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-php.test is already at PHP version: 8.2 + """ + + Scenario: Updating to an unsupported PHP version fails + Given a WordPress site 'bh-site-type-wp-php.test' exists + When I run 'bin/ee site update bh-site-type-wp-php.test --php=9.9' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'php' + """ + + Scenario Outline: Create a site on PHP + When I run 'bin/ee site create bh-site-type-wp-php.test --type=wp --php=' + Then the command should succeed + And the site 'bh-site-type-wp-php.test' info should contain: + | field | value | + | php_version | | + And the 'php' container of site 'bh-site-type-wp-php.test' should use image 'easyengine/:' + When In the 'php' container of site 'bh-site-type-wp-php.test' I run 'php -v' + Then STDOUT should contain: + """ + PHP + """ + And a GET request to 'http://bh-site-type-wp-php.test/' should return 200 + When I run 'bin/ee site delete bh-site-type-wp-php.test --yes' + Then the command should succeed + + Examples: + | php | name | image | runtime | + | 8.3 | 83 | php8.3 | 8.3. | + | 8.5 | 85 | php8.5 | 8.5. | + | latest | latest | php8.4 | 8.4. | + + # These pull the older php images (not present on a fresh host) and need an older WordPress for PHP < 7.4. + @slow + Scenario Outline: Create a site on legacy PHP + When I run 'bin/ee site create bh-site-type-wp-php.test --type=wp --php= --version=' + Then the command should succeed + And the 'php' container of site 'bh-site-type-wp-php.test' should use image 'easyengine/php:' + When In the 'php' container of site 'bh-site-type-wp-php.test' I run 'php -v' + Then STDOUT should contain: + """ + PHP . + """ + And a GET request to 'http://bh-site-type-wp-php.test/' should return 200 + When I run 'bin/ee site delete bh-site-type-wp-php.test --yes' + Then the command should succeed + + Examples: + | php | name | wp | + | 7.0 | 70 | 6.2.6 | + | 7.2 | 72 | 6.8.3 | + | 7.3 | 73 | 6.8.3 | + | 7.4 | 74 | latest | + | 8.0 | 80 | latest | + | 8.1 | 81 | latest | + + # PHP 5.6 uses its own custom.ini template (sendmail instead of msmtp). + @slow + Scenario: Create a site on PHP 5.6 + When I run 'bin/ee site create bh-site-type-wp-php56.test --type=wp --php=5.6 --version=6.2.6' + Then the command should succeed + And the 'php' container of site 'bh-site-type-wp-php56.test' should use image 'easyengine/php5.6:' + And the file '{SITES_DIR}/bh-site-type-wp-php56.test/config/php/php/conf.d/custom.ini' should contain: + """ + sendmail_path = /usr/sbin/sendmail -t -i -f no-reply@example.com + """ + And a GET request to 'http://bh-site-type-wp-php56.test/' should return 200 + + Scenario Outline: Unsupported PHP version is rejected before anything is created + When I run 'bin/ee site create bh-site-type-wp-phpbad.test --type=wp --php=' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'php' + """ + And the site 'bh-site-type-wp-phpbad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-phpbad.test' should not exist + + Examples: + | php | + | 7.1 | + | 9.9 | + | foo | diff --git a/features/proxy-cache.feature b/features/proxy-cache.feature new file mode 100644 index 0000000..9f7824b --- /dev/null +++ b/features/proxy-cache.feature @@ -0,0 +1,98 @@ +# Enabling or disabling proxy cache runs `docker restart` on the global nginx-proxy, which briefly interrupts every site on the host. +Feature: Proxy cache of WordPress sites (--proxy-cache) + + @destructive + Scenario: Create a site with proxy cache and custom limits + When I run 'bin/ee site create bh-site-type-wp-proxy.test --type=wp --proxy-cache=on --proxy-cache-max-time=30s --proxy-cache-max-size=bad' + Then the command should succeed + And STDERR should contain: + """ + Wrong value `bad` supplied to param: `proxy-cache-max-size`, replacing it with default:256m + """ + And STDOUT should contain: + """ + Proxy cache update on site bh-site-type-wp-proxy.test completed. + """ + # Proxy cache implies the redis page/object cache. + And the site 'bh-site-type-wp-proxy.test' info should contain: + | field | value | + | proxy_cache | on | + | cache_nginx_fullpage | 1 | + | cache_host | global-redis | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxy.test.conf' should contain: + """ + proxy_cache_path /var/cache/nginx/bh-site-type-wp-proxy.test levels=1:2 keys_zone=bh-site-type-wp-proxy-test:10m max_size=256m; + """ + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxy.test_location' should contain: + """ + proxy_cache bh-site-type-wp-proxy-test; + proxy_cache_valid 200 301 302 404 30s; + add_header X-Proxy-Cache $upstream_cache_status; + """ + And a GET request to 'http://bh-site-type-wp-proxy.test/' should return 200 within 60 seconds + And a GET request to 'http://bh-site-type-wp-proxy.test/' should have header 'X-Proxy-Cache: HIT' within 20 seconds + When I run 'bin/ee site info bh-site-type-wp-proxy.test' + Then STDOUT should contain: + """ + Proxy Cache + Enabled + """ + + @destructive + Scenario: Proxy cache can be turned off and is not turned on twice + Given a WordPress site 'bh-site-type-wp-proxy.test' exists created with '--proxy-cache=on' + When I run 'bin/ee site update bh-site-type-wp-proxy.test --proxy-cache=on' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-proxy.test already has proxy cache: on + """ + When I run 'bin/ee site update bh-site-type-wp-proxy.test --proxy-cache=off' + Then the command should succeed + And the site 'bh-site-type-wp-proxy.test' info should contain: + | field | value | + | proxy_cache | off | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxy.test.conf' should not exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxy.test_location' should not exist + And a GET request to 'http://bh-site-type-wp-proxy.test/' should return 200 within 60 seconds + And the response should not have header 'X-Proxy-Cache' + + @destructive + Scenario: Enable proxy cache on an existing site with update + Given a WordPress site 'bh-site-type-wp-proxyupd.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-proxyupd.test --proxy-cache=on --proxy-cache-max-size=1g --proxy-cache-max-time=xyz --proxy-cache-key-zone-size=20m' + Then the command should succeed + And STDERR should contain: + """ + Wrong value `xyz` supplied to param: `proxy-cache-max-time`, replacing it with default:1s + """ + And the site 'bh-site-type-wp-proxyupd.test' info should contain: + | field | value | + | proxy_cache | on | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxyupd.test.conf' should contain: + """ + keys_zone=bh-site-type-wp-proxyupd-test:20m max_size=1g; + """ + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxyupd.test_location' should contain: + """ + proxy_cache_valid 200 301 302 404 1s; + """ + When I run 'bin/ee site delete bh-site-type-wp-proxyupd.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-proxyupd.test.conf' should not exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/vhost.d/bh-site-type-wp-proxyupd.test_location' should not exist + + # Regression check (fixed in v4.13.2 by EasyEngine/site-command#510): the proxy purge also uses `docker exec -it`, so the files stay while "Proxy cache cleared" is printed. + @destructive + Scenario: ee site clean --proxy empties the proxy cache directory + Given a WordPress site 'bh-site-type-wp-proxyclean.test' exists created with '--proxy-cache=on --skip-install' + And I run 'docker exec services_global-nginx-proxy_1 sh -c "mkdir -p /var/cache/nginx/bh-site-type-wp-proxyclean.test/x && touch /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry"' + And the command should succeed + When I run 'bin/ee site clean bh-site-type-wp-proxyclean.test --proxy' + Then the command should succeed + And STDOUT should contain: + """ + Proxy cache cleared for bh-site-type-wp-proxyclean.test + """ + When I run 'docker exec services_global-nginx-proxy_1 test ! -e /var/cache/nginx/bh-site-type-wp-proxyclean.test/x/entry' + Then the command should succeed diff --git a/features/redirect.feature b/features/redirect.feature index da76c45..97cb464 100644 --- a/features/redirect.feature +++ b/features/redirect.feature @@ -1,19 +1,46 @@ -Feature: Site Redirection +Feature: www / non-www redirection of WordPress sites + # The www check curls the other host name from the host, so it needs a hosts entry before the site is created. + # Full installs: with --skip-install nothing waits for the proxy to load the vhost before the www check. 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=wp' - 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 I add a hosts entry for 'www.bh-site-type-wp-redir.test' + When I run 'bin/ee site create bh-site-type-wp-redir.test --type=wp' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should exist + And a GET request to 'http://www.bh-site-type-wp-redir.test/' should return 301 within 30 seconds + And the response should redirect to 'http://bh-site-type-wp-redir.test/' + And Request on 'localhost' with header 'Host: www.bh-site-type-wp-redir.test' should contain following headers: + | header | + | HTTP/1.1 301 Moved Permanently | + | Location: http://bh-site-type-wp-redir.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=wp' - 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 I add a hosts entry for 'bh-site-type-wp-redir2.test' + When I run 'bin/ee site create www.bh-site-type-wp-redir2.test --type=wp' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/www.bh-site-type-wp-redir2.test-redirect.conf' should exist + And a GET request to 'http://bh-site-type-wp-redir2.test/' should return 301 within 30 seconds + And the response should redirect to 'http://www.bh-site-type-wp-redir2.test/' + + Scenario: No redirect config when the www host does not resolve + When I run 'bin/ee site create bh-site-type-wp-redir3.test --type=wp --skip-install' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir3.test-redirect.conf' should not exist + And a GET request to 'http://www.bh-site-type-wp-redir3.test/' should return 503 + + Scenario: Deleting the site removes its redirect config + Given I add a hosts entry for 'www.bh-site-type-wp-redir.test' + And a WordPress site 'bh-site-type-wp-redir.test' exists + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should exist + When I run 'bin/ee site delete bh-site-type-wp-redir.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-redir.test-redirect.conf' should not exist + + # Regression check (fixed by EasyEngine/site-command#505): delete removed the redirect file without reloading nginx-proxy, so www kept redirecting to the deleted site. + Scenario: Deleting the site stops the www redirect + Given I add a hosts entry for 'www.bh-site-type-wp-redir4.test' + And a WordPress site 'bh-site-type-wp-redir4.test' exists + And a GET request to 'http://www.bh-site-type-wp-redir4.test/' should return 301 within 30 seconds + When I run 'bin/ee site delete bh-site-type-wp-redir4.test --yes' + Then the command should succeed + And a GET request to 'http://www.bh-site-type-wp-redir4.test/' should return 503 within 30 seconds diff --git a/features/site.feature b/features/site.feature index f996305..a1e285d 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1,4 +1,4 @@ -Feature: Site Command +Feature: WordPress site lifecycle (create, info, list, enable/disable, restart/reload, delete) Scenario: ee executable is command working correctly Given 'bin/ee' is installed @@ -17,71 +17,469 @@ Feature: Site Command usage: ee site """ - Scenario: Create wp site successfully - When I run 'bin/ee site create wp.test --type=wp' - Then After delay of 5 seconds - And The site 'wp.test' should have webroot - And The site 'wp.test' should have WordPress - And Request on 'wp.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: Create wp site successfully - When I run 'bin/ee site create wpcache.test --type=wp --cache' - Then After delay of 5 seconds - And The site 'wpcache.test' should have webroot - And The site 'wpcache.test' should have WordPress - And Request on 'wpcache.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: Create wpsubdir site successfully - When I run 'bin/ee site create wpsubdir.test --type=wp --mu=subdir' - And I create subsite '1' in 'wpsubdir.test' - Then After delay of 5 seconds - And The site 'wpsubdir.test' should have webroot - And The site 'wpsubdir.test' should have WordPress - And The site 'wpsubdir.test' should be 'subdir' multisite - And Request on 'wpsubdir.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: Create wpsubdom site successfully - When I run 'bin/ee site create wpsubdom.test --type=wp --mu=subdom' - And I create subsite '1' in 'wpsubdom.test' - Then After delay of 5 seconds - And The site 'wpsubdom.test' should have webroot - And The site 'wpsubdom.test' should have WordPress - And The site 'wpsubdom.test' should be 'subdomain' multisite - And Request on 'wpsubdom.test' should contain following headers: - | header | - | HTTP/1.1 200 OK | - - Scenario: List the sites + Scenario: Help of the wp site type documents every wp create flag + When I run 'bin/ee help site create --type=wp' + Then the command should succeed + And STDOUT should contain: + """ + Runs the standard WordPress Site installation. + [--cache] + [--vip] + [--mu=] + [--alias-domains=] + [--title=] + [--admin-user=<admin-user>] + [--admin-email=<admin-email>] + [--admin-pass=<admin-pass>] + [--allow-insecure-pass] + [--local-db] + [--with-local-redis] + [--public-dir] + [--php=<php-version>] + [--dbhost=<dbhost>] + [--dbprefix=<dbprefix>] + [--skip-check] + [--version=<version>] + [--skip-content] + [--skip-install] + [--skip-status-check] + [--proxy-cache=<on-or-off>] + [--force] + """ + + Scenario: Create without a site name prints the usage + When I run 'bin/ee site create --type=wp' + Then the command should fail + And the output should contain: + """ + usage: ee site create <site-name> + """ + + Scenario: Create a default WordPress site + When I run 'bin/ee site create bh-site-type-wp-basic.test --type=wp' + Then the command should succeed + And STDOUT should contain: + """ + Starting site creation. + Configuring project. + Creating WordPress site bh-site-type-wp-basic.test + Downloading and configuring WordPress. + Checking and verifying site-up status. + Installing WordPress site. + Success: http://bh-site-type-wp-basic.test has been created successfully! + Site entry created. + Creating cron entry + """ + And STDERR should not contain: + """ + Error: + """ + And the site 'bh-site-type-wp-basic.test' should exist + And the site 'bh-site-type-wp-basic.test' should be enabled + And the site 'bh-site-type-wp-basic.test' info should contain: + | field | value | + | site_type | wp | + | app_sub_type | wp | + | site_fs_path | {SITES_DIR}/bh-site-type-wp-basic.test | + | site_container_fs_path | /var/www/htdocs | + | site_ssl | <empty> | + | site_ssl_wildcard | 0 | + | cache_nginx_browser | 0 | + | cache_nginx_fullpage | 0 | + | cache_mysql_query | 0 | + | cache_app_object | 0 | + | cache_host | <empty> | + | php_version | 8.4 | + | db_host | global-db | + | db_port | 3306 | + | db_name | bh_site_type_wp_basic_test | + | db_user | /^bh-site-type-wp-basic\.test-.{6}$/ | + | db_password | <not empty> | + | db_root_password | <empty> | + | app_admin_url | bh-site-type-wp-basic.test | + | app_admin_email | admin@bh-site-type-wp-basic.test | + | app_admin_username | /^wp-user-\w{4,6}$/ | + | app_admin_password | /^.{18}$/ | + | app_mail | postfix | + | alias_domains | bh-site-type-wp-basic.test | + | mailhog_enabled | 0 | + # Webroot layout and config files. + And The site 'bh-site-type-wp-basic.test' should have webroot + And The site 'bh-site-type-wp-basic.test' should have WordPress + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/wp-load.php' should exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/wp-config.php' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/check.html' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/htdocs/wp-cli.yml' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/conf.d/main.conf' should contain: + """ + server bh-site-type-wp-basic.test_php:9000; + server_name bh-site-type-wp-basic.test; + root /var/www/htdocs; + # PHP NGINX CONFIGURATION + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/conf.d/main.conf' should not contain: + """ + # Redis NGINX CONFIGURATION + # WPSUBDIRECTORY NGINX CONFIGURATION + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/custom/user.conf' should exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/nginx/custom/admin-tools.conf' should exist + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/config/php/php/conf.d/custom.ini' should contain: + """ + upload_max_filesize = 100M + sendmail_path = /usr/bin/msmtp -t + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/.env' should contain: + """ + VIRTUAL_HOST=bh-site-type-wp-basic.test + WORDPRESS_DB_HOST=global-db:3306 + WORDPRESS_DB_NAME=bh_site_type_wp_basic_test + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/.env' should not contain: + """ + MYSQL_ROOT_PASSWORD + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml' should contain: + """ + image: easyengine/php8.4: + - VIRTUAL_HOST + - VIRTUAL_PATH=/ + - HSTS=off + global-backend-network: + HTTPS_METHOD=nohttps + """ + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml' should not contain: + """ + easyengine/mariadb + easyengine/redis + """ + # Containers, volumes and networks. + And the site 'bh-site-type-wp-basic.test' should have 3 running containers + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should be running + And the 'php' container of site 'bh-site-type-wp-basic.test' should be running + And the 'postfix' container of site 'bh-site-type-wp-basic.test' should be running + And the 'mailhog' container of site 'bh-site-type-wp-basic.test' should not exist + And the 'db' container of site 'bh-site-type-wp-basic.test' should not exist + And the 'redis' container of site 'bh-site-type-wp-basic.test' should not exist + And the 'php' container of site 'bh-site-type-wp-basic.test' should use image 'easyengine/php8.4:' + And the 'php' container of site 'bh-site-type-wp-basic.test' should have restart policy 'always' + And the 'php' container of site 'bh-site-type-wp-basic.test' should be connected to network 'ee-global-backend-network' + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should be connected to network 'ee-global-frontend-network' + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should not be connected to network 'ee-global-backend-network' + And the docker network 'bh-site-type-wp-basic.test' should exist + And the site 'bh-site-type-wp-basic.test' should have 8 docker volumes + # Global DB, hosts entry, cron and proxy side effects. + And the global database should have a database named 'bh_site_type_wp_basic_test' + And the global database should have the user of site 'bh-site-type-wp-basic.test' + And the hosts file should contain 'bh-site-type-wp-basic.test' + And the EE database table 'cron' should have 1 rows where 'site_url' is 'bh-site-type-wp-basic.test' + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-basic.test-redirect.conf' should not exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/conf.d/bh-site-type-wp-basic.test.conf' should not exist + # WordPress itself. + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 + And the response body should contain 'bh-site-type-wp-basic.test' + And a GET request to 'http://bh-site-type-wp-basic.test/wp-login.php' should return 200 + And a GET request to 'http://www.bh-site-type-wp-basic.test/' should return 503 + + Scenario: WordPress configuration written by create + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run WP-CLI 'core is-installed' on site 'bh-site-type-wp-basic.test' + Then the command should succeed + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-basic.test + """ + When I run WP-CLI 'option get permalink_structure' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + /%year%/%monthnum%/%day%/%postname%/ + """ + When I run WP-CLI 'config get DB_HOST' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + global-db:3306 + """ + When I run WP-CLI 'config get DB_NAME' on site 'bh-site-type-wp-basic.test' + Then STDOUT should contain: + """ + bh_site_type_wp_basic_test + """ + When I run WP-CLI 'config get WP_DEBUG_DISPLAY' on site 'bh-site-type-wp-basic.test' + Then the command should succeed + When I run WP-CLI 'config has COOKIE_DOMAIN' on site 'bh-site-type-wp-basic.test' + Then the command should fail + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/app/wp-config.php' should contain: + """ + $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https' + define( 'WP_DEBUG_LOG', false ); + define( 'SCRIPT_DEBUG', false ); + """ + When I run WP-CLI 'user list --role=administrator --field=user_login' on site 'bh-site-type-wp-basic.test' + Then STDOUT should match '/^wp-user-\w{4,6}$/m' + + Scenario: WordPress hardening rules of the generated nginx config + Given a WordPress site 'bh-site-type-wp-basic.test' exists + Then a GET request to 'http://bh-site-type-wp-basic.test/readme.html' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/license.txt' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/wp-config.txt' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/.htaccess' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/backup.sql' should return 403 + And a GET request to 'http://bh-site-type-wp-basic.test/wp-content/uploads/evil.php' should return 403 + + Scenario: Creating an existing site fails and leaves it untouched + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site create bh-site-type-wp-basic.test --type=wp' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-basic.test already exists. + """ + And the site 'bh-site-type-wp-basic.test' should be enabled + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 + + Scenario: Site info as a table + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site info bh-site-type-wp-basic.test' + Then the command should succeed + And STDOUT should contain: + """ + http://bh-site-type-wp-basic.test + {SITES_DIR}/bh-site-type-wp-basic.test + Site Title + WordPress Username + WordPress Password + Alias Domains + None + global-db + bh_site_type_wp_basic_test + admin@bh-site-type-wp-basic.test + Not Enabled + Proxy Cache + Off + """ + And STDOUT should not contain: + """ + DB Root Password + SSL Wildcard + Access admin-tools + """ + + Scenario: Site info as JSON + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site info bh-site-type-wp-basic.test --format=json' + Then the command should succeed + And STDOUT should be valid json + And STDOUT should contain: + """ + "site_url":"bh-site-type-wp-basic.test" + "site_type":"wp" + """ + + Scenario: Site info detects the site from the current directory + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When From the directory '{SITES_DIR}/bh-site-type-wp-basic.test/app' I run 'bin/ee site info' + Then the command should succeed + And STDOUT should contain: + """ + http://bh-site-type-wp-basic.test + """ + + Scenario: Site info of an unknown site fails + When I run 'bin/ee site info bh-site-type-wp-unknown.test' + Then the command should fail + And STDERR should contain: + """ + Could not find the site you wish to run site info command on. + """ + + Scenario: Update of an unknown site fails + When I run 'bin/ee site update bh-site-type-wp-unknown.test --php=8.3' + Then the command should fail + And STDERR should contain: + """ + Could not find the site you wish to run site update command on. + """ + + Scenario: Invalid info format is rejected + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site info bh-site-type-wp-basic.test --format=yaml' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'format' + """ + + Scenario: List shows the site in every format + Given a WordPress site 'bh-site-type-wp-basic.test' exists When I run 'bin/ee site list --format=text' - Then STDOUT should return exactly - """ - wp.test - wpcache.test - wpsubdir.test - wpsubdom.test - """ + Then STDOUT should contain: + """ + bh-site-type-wp-basic.test + """ + When I run 'bin/ee site list --format=json' + Then STDOUT should be valid json + # Pretty-printed or compact depending on the terminal. + And STDOUT should match '/\{\s*"site":\s*"bh-site-type-wp-basic\.test",\s*"status":\s*"enabled"\s*\}/' + When I run 'bin/ee site list --enabled --format=text' + Then STDOUT should contain: + """ + bh-site-type-wp-basic.test + """ - Scenario: Delete the sites - When I run 'bin/ee site delete wp.test --yes' - Then STDOUT should return something like - """ - Site wp.test deleted. - """ - And STDERR should return exactly - """ - """ - And The 'wp.test' db entry should be removed - And The 'wp.test' webroot should be removed - And Following containers of site 'wp.test' should be removed: - | container | - | nginx | - | php | - | db | - | redis | - | phpmyadmin | + Scenario: Cache clean on a site without cache fails + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site clean bh-site-type-wp-basic.test' + Then the command should fail + And STDERR should contain: + """ + Site object cache is not enabled. + Site page cache is not enabled. + """ + When I run 'bin/ee site clean bh-site-type-wp-basic.test --proxy' + Then the command should fail + And STDERR should contain: + """ + Proxy cache is not enabled on site. + """ + + Scenario: Share without an active share fails before touching WordPress + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site share bh-site-type-wp-basic.test --disable' + Then the command should fail + And STDERR should contain: + """ + bh-site-type-wp-basic.test does not have active share running. + """ + And STDOUT should not contain: + """ + Running additional WordPress configurations. + """ + + Scenario: Restart and reload the WordPress containers + Given a WordPress site 'bh-site-type-wp-basic.test' exists + And I note the start time of the 'php' container of site 'bh-site-type-wp-basic.test' + And I note the start time of the 'nginx' container of site 'bh-site-type-wp-basic.test' + When I run 'bin/ee site restart bh-site-type-wp-basic.test --php' + Then the command should succeed + And the 'php' container of site 'bh-site-type-wp-basic.test' should have been restarted + When I run 'bin/ee site restart bh-site-type-wp-basic.test --nginx' + Then the command should succeed + And the 'nginx' container of site 'bh-site-type-wp-basic.test' should have been restarted + When I run 'bin/ee site restart bh-site-type-wp-basic.test' + Then the command should succeed + When I run 'bin/ee site restart bh-site-type-wp-basic.test --all' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test --php' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test --nginx' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test' + Then the command should succeed + When I run 'bin/ee site reload bh-site-type-wp-basic.test --all' + Then the command should succeed + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 within 60 seconds + + Scenario: Refresh regenerates docker-compose.yml and keeps the site working + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run '/bin/bash -c "echo \# stale >> {SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml"' + And I run 'bin/ee site refresh bh-site-type-wp-basic.test' + Then the command should succeed + And the file '{SITES_DIR}/bh-site-type-wp-basic.test/docker-compose.yml' should not contain: + """ + # stale + """ + And the site 'bh-site-type-wp-basic.test' should have 3 running containers + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 within 60 seconds + + Scenario: Disable and enable a WordPress site + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site disable bh-site-type-wp-basic.test' + Then the command should succeed + And STDOUT should contain: + """ + Site bh-site-type-wp-basic.test disabled. + """ + And the site 'bh-site-type-wp-basic.test' should be disabled + And the site 'bh-site-type-wp-basic.test' should have 0 running containers + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 503 within 30 seconds + When I run 'bin/ee site list --disabled --format=text' + Then STDOUT should contain: + """ + bh-site-type-wp-basic.test + """ + # Info works on a disabled site, update/restart/reload refuse it. + When I run 'bin/ee site info bh-site-type-wp-basic.test' + Then the command should succeed + When I run 'bin/ee site update bh-site-type-wp-basic.test --php=8.3' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-basic.test is not enabled. Use `ee site enable bh-site-type-wp-basic.test` to enable it. + """ + When I run 'bin/ee site restart bh-site-type-wp-basic.test' + Then the command should fail + When I run 'bin/ee site reload bh-site-type-wp-basic.test' + Then the command should fail + When I run 'bin/ee site enable bh-site-type-wp-basic.test' + Then the command should succeed + And STDOUT should contain: + """ + Site bh-site-type-wp-basic.test enabled. + Post enable configurations complete. + """ + And the site 'bh-site-type-wp-basic.test' should be enabled + And the site 'bh-site-type-wp-basic.test' should have 3 running containers + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 200 within 60 seconds + When I run 'bin/ee site enable bh-site-type-wp-basic.test' + Then the command should fail + And STDERR should contain: + """ + bh-site-type-wp-basic.test is already enabled! + """ + + Scenario: Declining the delete confirmation keeps the site + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run '/bin/bash -c "echo n | bin/ee site delete bh-site-type-wp-basic.test"' + Then STDOUT should contain: + """ + Are you sure you want to delete bh-site-type-wp-basic.test? + """ + And the site 'bh-site-type-wp-basic.test' should exist + And the 'php' container of site 'bh-site-type-wp-basic.test' should be running + + Scenario: Delete removes every trace of the site + Given a WordPress site 'bh-site-type-wp-basic.test' exists + When I run 'bin/ee site delete bh-site-type-wp-basic.test --yes' + Then the command should succeed + And STDOUT should contain: + """ + Site bh-site-type-wp-basic.test deleted. + """ + And STDERR should not contain: + """ + Error: + """ + And The 'bh-site-type-wp-basic.test' db entry should be removed + And The 'bh-site-type-wp-basic.test' webroot should be removed + And Following containers of site 'bh-site-type-wp-basic.test' should be removed: + | container | + | nginx | + | php | + | postfix | + | mailhog | + And the site 'bh-site-type-wp-basic.test' should have 0 docker volumes + And the docker network 'bh-site-type-wp-basic.test' should not exist + And the global database should not have a database named 'bh_site_type_wp_basic_test' + And the global database should not have users starting with 'bh-site-type-wp-basic.test-' + And the hosts file should not contain 'bh-site-type-wp-basic.test' + And the EE database table 'cron' should have 0 rows where 'site_url' is 'bh-site-type-wp-basic.test' + And a GET request to 'http://bh-site-type-wp-basic.test/' should return 503 + + Scenario: Deleting a missing site fails + When I run 'bin/ee site delete bh-site-type-wp-missing.test --yes' + Then the command should fail + And STDERR should contain: + """ + Site bh-site-type-wp-missing.test does not exist. + """ diff --git a/features/ssl.feature b/features/ssl.feature new file mode 100644 index 0000000..9dd5a7c --- /dev/null +++ b/features/ssl.feature @@ -0,0 +1,246 @@ +Feature: SSL of WordPress sites (--ssl=self|custom|inherit|le and ssl updates) + + Scenario: Create a site with a self-signed certificate + When I run 'bin/ee site create bh-site-type-wp-self.test --type=wp --ssl=self' + Then the command should succeed + And STDOUT should contain: + """ + Success: https://bh-site-type-wp-self.test has been created successfully! + """ + # Self-signed certs cover *.site, so the site is recorded as wildcard. + And the site 'bh-site-type-wp-self.test' info should contain: + | field | value | + | site_ssl | self | + | site_ssl_wildcard | 1 | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-self.test.crt' should exist + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-self.test.key' should exist + And the hosts file should contain 'bh-site-type-wp-self.test' + And the file '{SITES_DIR}/bh-site-type-wp-self.test/docker-compose.yml' should not contain: + """ + HTTPS_METHOD=nohttps + """ + And a GET request to 'https://bh-site-type-wp-self.test/' should return 200 within 30 seconds + And a GET request to 'http://bh-site-type-wp-self.test/' should return 301 + And the response should redirect to 'https://bh-site-type-wp-self.test/' + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-self.test' + Then STDOUT should contain: + """ + https://bh-site-type-wp-self.test + """ + When I run 'bin/ee site info bh-site-type-wp-self.test' + Then STDOUT should contain: + """ + https://bh-site-type-wp-self.test + SSL Wildcard + Yes + """ + When I run 'bin/ee site share bh-site-type-wp-self.test' + Then the command should fail + And STDERR should contain: + """ + site share is not yet supported for ssl sites. + """ + + Scenario: A subdomain site inherits the wildcard certificate of its parent + Given a WordPress site 'bh-site-type-wp-self.test' exists created with '--ssl=self' + When I run 'bin/ee site create child.bh-site-type-wp-self.test --type=wp --ssl=inherit' + Then the command should succeed + And STDOUT should contain: + """ + Inherited certs from parent + """ + And the site 'child.bh-site-type-wp-self.test' info should contain: + | field | value | + | site_ssl | inherit | + | site_ssl_wildcard | 0 | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/child.bh-site-type-wp-self.test.crt' should not exist + And a GET request to 'https://child.bh-site-type-wp-self.test/' should return 200 within 30 seconds + When I run 'bin/ee site delete child.bh-site-type-wp-self.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-self.test.crt' should exist + + Scenario: Inherit together with --wildcard is rejected + When I run 'bin/ee site create child2.bh-site-type-wp-self.test --type=wp --ssl=inherit --wildcard' + Then the command should fail + And STDERR should contain: + """ + --wildcard or --mu=subdom flag can not be passed together with --ssl=inherit flag. + """ + And the site 'child2.bh-site-type-wp-self.test' should not exist + + Scenario: Inherit without a parent site rolls back + When I run 'bin/ee site create orphan.bh-site-type-wp-noparent.test --type=wp --ssl=inherit' + Then the command should fail + And STDERR should contain: + """ + Unable to find existing site: bh-site-type-wp-noparent.test + Initiating clean-up. + """ + And the site 'orphan.bh-site-type-wp-noparent.test' should not exist + And the directory '{SITES_DIR}/orphan.bh-site-type-wp-noparent.test' should not exist + And the global database should not have a database named 'orphan_bh_site_type_wp_noparent_test' + + Scenario: Create a site with a custom certificate + Given a self-signed certificate for 'bh-site-type-wp-custom.test' exists in '{TMP_DIR}/certs' + When I run 'bin/ee site create bh-site-type-wp-custom.test --type=wp --ssl=custom --ssl-key={TMP_DIR}/certs/bh-site-type-wp-custom.test.key --ssl-crt={TMP_DIR}/certs/bh-site-type-wp-custom.test.crt' + Then the command should succeed + And the site 'bh-site-type-wp-custom.test' info should contain: + | field | value | + | site_ssl | custom | + And the files '{TMP_DIR}/certs/bh-site-type-wp-custom.test.crt' and '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-custom.test.crt' should be identical + And the files '{TMP_DIR}/certs/bh-site-type-wp-custom.test.key' and '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-custom.test.key' should be identical + And a GET request to 'https://bh-site-type-wp-custom.test/' should return 200 within 30 seconds + When I run 'bin/ee site delete bh-site-type-wp-custom.test --yes' + Then the command should succeed + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-custom.test.crt' should not exist + + Scenario: Custom SSL without key and certificate is rejected + When I run 'bin/ee site create bh-site-type-wp-custom2.test --type=wp --ssl=custom' + Then the command should fail + And STDERR should contain: + """ + Pass --ssl-key and --ssl-crt for custom SSL + """ + And the site 'bh-site-type-wp-custom2.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-custom2.test' should not exist + + Scenario: Custom SSL with missing files is rejected + When I run 'bin/ee site create bh-site-type-wp-custom2.test --type=wp --ssl=custom --ssl-key=/nonexistent/bh.key --ssl-crt=/nonexistent/bh.crt' + Then the command should fail + And STDERR should contain: + """ + ssl-key OR ssl-crt path does not exist + """ + And the site 'bh-site-type-wp-custom2.test' should not exist + + # Create's `[--ssl]` is a flag, so the value is only rejected in init_ssl(), after the site was provisioned. + Scenario: An unknown --ssl value rolls the whole create back + When I run 'bin/ee site create bh-site-type-wp-sslbad.test --type=wp --ssl=bogus' + Then the command should fail + And STDERR should contain: + """ + Unrecognized value in --ssl flag: bogus + Initiating clean-up. + """ + And the site 'bh-site-type-wp-sslbad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-sslbad.test' should not exist + And the site 'bh-site-type-wp-sslbad.test' should have 0 docker volumes + And the global database should not have a database named 'bh_site_type_wp_sslbad_test' + And the hosts file should not contain 'bh-site-type-wp-sslbad.test' + + Scenario: Enabling and disabling SSL on a WordPress site runs search-replace with a DB backup + Given a WordPress site 'bh-site-type-wp-sslupd.test' exists + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=self' + Then the command should succeed + And STDOUT should contain: + """ + Starting SSL update for: bh-site-type-wp-sslupd.test + Running search-replace. + Taking database backup before search-replace. + Success: Successfully completed search-replace. + Success: Enabled SSL for bh-site-type-wp-sslupd.test + """ + And the file '{EE_ROOT_DIR}/.backup/bh-site-type-wp-sslupd.test/bh-site-type-wp-sslupd.test.db' should exist + And the file '{SITES_DIR}/bh-site-type-wp-sslupd.test/app/htdocs/bh-site-type-wp-sslupd.test.db' should not exist + And the site 'bh-site-type-wp-sslupd.test' info should contain: + | field | value | + | site_ssl | self | + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-sslupd.test' + Then STDOUT should contain: + """ + https://bh-site-type-wp-sslupd.test + """ + And a GET request to 'https://bh-site-type-wp-sslupd.test/' should return 200 within 30 seconds + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=self' + Then the command should fail + And STDERR should contain: + """ + SSL is already enabled on bh-site-type-wp-sslupd.test + """ + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=off' + Then the command should succeed + And STDOUT should contain: + """ + Success: Disabled SSL for bh-site-type-wp-sslupd.test + """ + And the site 'bh-site-type-wp-sslupd.test' info should contain: + | field | value | + | site_ssl | <empty> | + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-sslupd.test' + Then STDOUT should contain: + """ + http://bh-site-type-wp-sslupd.test + """ + And a GET request to 'http://bh-site-type-wp-sslupd.test/' should return 200 within 30 seconds + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=off' + Then the command should fail + And STDERR should contain: + """ + SSL is already disabled on bh-site-type-wp-sslupd.test + """ + + # Regression check (fixed by EasyEngine/site-command#505): update refused `--ssl=off` for self-signed creates, which store site_ssl_wildcard=1. + Scenario: SSL of a site created with --ssl=self can be turned off + Given a WordPress site 'bh-site-type-wp-selfoff.test' exists created with '--ssl=self --skip-install' + When I run 'bin/ee site update bh-site-type-wp-selfoff.test --ssl=off' + Then the command should succeed + And the site 'bh-site-type-wp-selfoff.test' info should contain: + | field | value | + | site_ssl | <empty> | + + Scenario: Invalid SSL updates are rejected + Given a WordPress site 'bh-site-type-wp-sslupd.test' exists + When I run 'bin/ee site update bh-site-type-wp-sslupd.test --ssl=bogus' + Then the command should fail + And STDERR should contain: + """ + Invalid value specified for 'ssl' + """ + # A site that never had SSL: sslupd had --ssl=self above, which keeps site_ssl_wildcard=1 after --ssl=off (EasyEngine/site-command#505), so it would request a wildcard certificate. + Given a WordPress site 'bh-site-type-wp-nossl.test' exists created with '--skip-install' + When I run 'bin/ee site update bh-site-type-wp-nossl.test --ssl=le --wildcard' + Then the command should fail + And STDERR should contain: + """ + Update from normal SSL to wildcard SSL is not supported yet. + """ + + # Uses one Let's Encrypt issuance (the name resolves to this host through *.ee4-test.rt.gw). + @le + Scenario: Create a WordPress site with a Let's Encrypt certificate + When I run 'bin/ee site create bh-site-type-wp-le1.ee4-test.rt.gw --type=wp --ssl=le' + Then the command should succeed + And the site 'bh-site-type-wp-le1.ee4-test.rt.gw' info should contain: + | field | value | + | site_ssl | le | + | site_ssl_wildcard | 0 | + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should exist + And the hosts file should not contain 'bh-site-type-wp-le1.ee4-test.rt.gw' + # The proxy reload is asynchronous: right after create the old workers can still answer, or drop the TLS connection. + When I wait until "curl -sS -o /dev/null https://bh-site-type-wp-le1.ee4-test.rt.gw/" succeeds within 60 seconds + And I run 'curl -sS -o /dev/null -w "%{http_code} %{ssl_verify_result}" https://bh-site-type-wp-le1.ee4-test.rt.gw/' + Then STDOUT should contain: + """ + 200 0 + """ + When I run WP-CLI 'option get home' on site 'bh-site-type-wp-le1.ee4-test.rt.gw' + Then STDOUT should contain: + """ + https://bh-site-type-wp-le1.ee4-test.rt.gw + """ + When I run 'bin/ee site delete bh-site-type-wp-le1.ee4-test.rt.gw --yes' + Then the command should succeed + And STDOUT should contain: + """ + Revoking certificate. + Removing ssl certs and other config files. + """ + And the file '{EE_ROOT_DIR}/services/nginx-proxy/certs/bh-site-type-wp-le1.ee4-test.rt.gw.crt' should not exist + And the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/certs/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist + + # Regression check (fixed by EasyEngine/site-command#505): deleting an LE site left acme-conf/var entries for www. Checks the leftovers of the scenario above (no new issuance). + @le + Scenario: Deleting a Let's Encrypt site leaves no ACME leftovers + Given the site 'bh-site-type-wp-le1.ee4-test.rt.gw' was deleted earlier in this feature + Then the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/var/bh-site-type-wp-le1.ee4-test.rt.gw' should not exist + And the directory '{EE_ROOT_DIR}/services/nginx-proxy/acme-conf/var/www.bh-site-type-wp-le1.ee4-test.rt.gw' should not exist diff --git a/features/vip.feature b/features/vip.feature new file mode 100644 index 0000000..51df637 --- /dev/null +++ b/features/vip.feature @@ -0,0 +1,47 @@ +# Needs git on the host and access to github.com (clones vip-go-skeleton and the large vip-go-mu-plugins-built repo). +@network +Feature: WordPress VIP Go sites (--vip) + + Scenario: Create a VIP site from the default skeleton with cache + When I run 'bin/ee site create bh-site-type-wp-vip.test --type=wp --vip --cache' + Then the command should succeed + And STDOUT should contain: + """ + VIP Skeleton repo will be used for wp-content directory: https://github.com/Automattic/vip-go-skeleton.git + Repo access check completed. + Setting up VIP Go environment. + VIP Go environment setup completed. + """ + And STDERR should contain: + """ + Nginx-helper and wp-redis plugin is installed to enable cache. + """ + And the site 'bh-site-type-wp-vip.test' should have the site meta 'is_vip' set to '1' + And the site 'bh-site-type-wp-vip.test' should have the site meta 'vip_repo_url' set to 'https://github.com/Automattic/vip-go-skeleton.git' + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content/.git' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content/mu-plugins' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content/uploads' should exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/mu-plugins' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content-bkp' should not exist + And the file '{SITES_DIR}/bh-site-type-wp-vip.test/app/wp-config.php' should contain: + """ + /wp-content/mu-plugins/000-pre-vip-config/requires.php + /wp-content/vip-config/vip-config.php + """ + When I run 'stat -c %U {SITES_DIR}/bh-site-type-wp-vip.test/app/htdocs/wp-content' + Then STDOUT should contain: + """ + www-data + """ + And a GET request to 'http://bh-site-type-wp-vip.test/' should return 200 + + Scenario: An inaccessible VIP repository is rejected before anything is created + When I run 'env GIT_TERMINAL_PROMPT=0 bin/ee site create bh-site-type-wp-vipbad.test --type=wp --vip=https://github.com/Automattic/bh-site-type-wp-does-not-exist.git' + Then the command should fail + And STDERR should contain: + """ + Your repo is being cloned at wp-content directory make sure the repo has wp-content data. + Could not read from remote repository. Please make sure you have the correct access rights and the repository exists. + """ + And the site 'bh-site-type-wp-vipbad.test' should not exist + And the directory '{SITES_DIR}/bh-site-type-wp-vipbad.test' should not exist