diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1bd4822..56855f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,7 +17,7 @@ permissions: jobs: tests: - # tests/coverage.sh measures six shell files (COVERAGE.md). The threshold + # tests/coverage.sh measures eight shell files (COVERAGE.md). The threshold # is the lowest of them and is only ever raised (decision 0006). This job # produces the check "tests / coverage", the required status on master. The # job id is part of that name, so renaming it silently detaches the diff --git a/COVERAGE.md b/COVERAGE.md index ed70675..5497876 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -4,12 +4,14 @@ Standard: decisions 0003 (90 percent per repository, 95 for code the project writes) and 0004 (bats plus kcov for shell; a build and a boot on LXC as the acceptance test of a recipe, docs/org-plan.md section 1). -## Measured 2026-09-27 +## Measured 2026-09-28 | File | Test | Lines | Note | | --- | --- | --- | --- | | `overlay/usr/lib/inithooks/lib/wordpress.sh` | `tests/wordpress.bats` (40 tests) | 99.00 percent (99/100) under kcov | every function and every branch | | `overlay/usr/lib/inithooks/firstboot.d/40wordpress` | `tests/hook.bats` (29 tests) | 97.73 percent (43/44) under kcov | the hook itself, run for real | +| `overlay/usr/local/bin/keel-wp` | `tests/wrappers.bats` (25 tests) | 100 percent (8/8) under kcov | both cache branches, the quoting, the exit code it hands back, `DEBUG`, and the `turnkey-wp` link run for real | +| `overlay/usr/local/sbin/keel-wordpress-update` | `tests/wrappers.bats` (the same 25) | 100 percent (21/21) under kcov | both guards refused and satisfied, each wp-cli call made to fail, the whole ownership boundary, and the two names it must not take from the environment | | `tests/lib/boot-test-lib.sh` | `tests/boot-test.bats` (73 tests) | 98.95 percent (282/285) under kcov | parsing, addresses, deadlines, the container marks, every verdict, and the image carrying none of the build time archive files | | `conf.d/zzz-keel-archive` | `tests/keel-archive.bats` (13 tests) | 100 percent (26/26) under kcov | every way it enables and every way it refuses, including a staging keyring left in the image | | `conf.d/zz-project-packages` | `tests/project-packages.bats` (14 tests) | 100 percent (31/31) under kcov | shared with keel-nodebb, where the pattern is maintained | @@ -19,21 +21,58 @@ acceptance test of a recipe, docs/org-plan.md section 1). | `conf.d/main` | the build | integration only | build time script, 0004 pragmatic limits | | `tests/boot-test.sh` | itself | integration only | the thin main of the acceptance test: keel and LXC as root | -Total over the six measured shell files: **99.07 percent (535/540)**, 196 bats -tests, none failing. `tests/coverage.sh` fails below `COVERAGE_THRESHOLD`, which the workflow +Total over the eight measured shell files: **99.12 percent (564/569)**, 221 +bats tests, none failing. `tests/coverage.sh` fails below `COVERAGE_THRESHOLD`, which the workflow sets to **97**, the lowest measured file. It is only ever raised (decision 0006). $ COVERAGE_THRESHOLD=97 tests/coverage.sh kcov line coverage (threshold 97 percent): + 100.00 21/21 keel-wordpress-update + 100.00 8/8 keel-wp 100.00 31/31 zz-project-packages 100.00 26/26 zzz-keel-archive + 99.00 99/100 wordpress.sh 97.73 43/44 40wordpress 98.95 282/285 boot-test-lib.sh - 99.00 99/100 wordpress.sh 100.00 54/54 keel-archive-check - 100.00 29/29 zz-project-packages - 100.00 26/26 keel-archive-check + +### The two operator commands, and the link beside each + +`keel-wp` and `keel-wordpress-update` are the commands an operator types, and +they are written by this overlay, so decision 0003's 95 percent applies to +them. They were at nothing until 2026-09-28 and are now at 100 percent of +their lines with every branch driven: the cache directory both present and +absent, a `chown` that fails, the exit code handed back, `DEBUG`, the root +guard and the is-this-a-WordPress guard each refused and satisfied, and each +of the two wp-cli calls made to fail so the script stops before it touches +ownership. + +Two of the tests assert a name the updater must **not** read. It runs as root +and rewrites the owner and mode of everything under its target, so it takes +that target from `KEEL_TEST_WPROOT` and not from `WPROOT`, which an operator +may already be exporting and which `conf.d/main` uses for the same path; and +it takes the web user from `KEEL_TEST_WP_USER` and never from `USER`, which +in root's login environment is `root`. The web user in these tests is the +sentinel `keel-test-web-user`, deliberately not `$(id -un)`: with the expected +value equal to `$USER`, neither assertion could tell the two apart. + +The compatibility names `turnkey-wp` and `turnkey-wordpress-update` are +symlinks (decision 0015 of the handbook) and are **run**, not inspected. +`test -L` says a link exists; it does not say the appliance still answers to +the old name. Two of those tests copy the whole overlay with `cp -TdR`, which +is literally what `fab-apply-overlay` executes, do it twice because the +Makefile applies this overlay twice, and then run the copied command. That is +the build's own copy step, so the link is proved to survive it rather than +assumed to. + +What makes it survive is worth stating correctly, because a copy step is the +kind of thing that gets written again from this note. `-R` copies a symlink as +a symlink unless `-L` is given: `-P` is already the default under `-R`, and +`-d` only adds `--preserve=links`, which is about **hard** links and does +nothing for this. So the property is the absence of `-L`, not the presence of +`-d`, and a test asserts exactly that: a plain `cp -TR` still yields a working +`turnkey-wp`, and `cp -TLR` turns it into a second regular file. ### The five lines that are not covered, and why diff --git a/README.rst b/README.rst index 097c78c..4fe3370 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ and on top of that: directories intentionally contain web-writable executable code; install only updates and extensions you trust. - WordPress core is root-owned and does not update automatically. Apply a - supervised core update as ``root`` with ``turnkey-wordpress-update``. + supervised core update as ``root`` with ``keel-wordpress-update``. The command verifies official WordPress core checksums and restores the appliance ownership boundary after updating. @@ -170,7 +170,7 @@ it either: it is unpacked from a release archive pinned by digest in checksums. WordPress core therefore updates through WordPress's own mechanism, either from the dashboard or, preferably on this appliance, with:: - turnkey-wordpress-update + keel-wordpress-update which runs ``wp core update``, verifies WordPress's checksums again and puts the ownership boundary back: core and ``wp-config.php`` root owned, and only diff --git a/changelog b/changelog index d722e4f..d9ad656 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,68 @@ +turnkey-wordpress-19.0 (4) turnkey; urgency=low + + * The two operator commands this appliance writes itself now carry the + project's own name: /usr/local/bin/keel-wp and + /usr/local/sbin/keel-wordpress-update are the real files, and + /usr/local/bin/turnkey-wp and /usr/local/sbin/turnkey-wordpress-update + are symlinks to them. Both names keep working, which is the point: an + operator who pasted a command out of TurnKey's documentation, or who + wrote a script against turnkey-wp last year, must not silently lose it. + The general rule is decision 0015 of the handbook (tracker#12). + + * The symlinks are committed into the overlay rather than made in + conf.d/main. fab-apply-overlay copies an overlay with "cp -TdR", and what + keeps a symlink a symlink there is -R without -L: -P is already the + default under -R, and -d only adds --preserve=links, which is about hard + links. So the property is the absence of -L, and that is what the tests + assert -- a plain "cp -TR" still gives a working turnkey-wp and "cp -TLR" + turns it into a second regular file. This recipe also applies its overlay + twice, once through COMMON_OVERLAYS and once as the product-local + ROOT_OVERLAY, and a second copy over an existing link still leaves a link. + Every one of these is asserted by running the copied command, not by + looking at it. + + * Both links are relative, turnkey-wp -> keel-wp rather than an absolute + path, because an absolute link does not resolve inside fab-chroot and + conf.d/main calls the command at build time. + + * Nothing about what either command does has changed. keel-wp still runs + wp-cli through runuser as www-data, never as root, with an explicit + --path, and still owns /var/www/.wp-cli; keel-wordpress-update still + refuses a caller who is not root, updates core, verifies WordPress's own + per file checksums and puts the ownership boundary back. The refusal now + names whichever of the two names was typed. + + * keel-wordpress-update reads its WordPress root, its web user and its + wp-cli path from KEEL_TEST_WPROOT, KEEL_TEST_WP_USER and KEEL_TEST_WP_CLI + when those are set, so the tests can drive it against a scratch tree. The + prefix is deliberate rather than decorative: the script runs as root and + rewrites the owner and the mode of every file under its target, so the + name that chooses the target must not be one an operator could already be + exporting for something else, and WPROOT is exactly such a name -- + conf.d/main of this repository uses it for this same path. keel-wp gained + no new knob at all; its WP_DIR, WP_USR and WP_CACHE are the ones it + already shipped with. + + * keel-wordpress-update now refuses a target that is not a WordPress, by + the same wp-includes/version.php that conf.d/main asserts after + unpacking, before it rewrites anything. It reads its web user from + KEEL_TEST_WP_USER and never from USER: the previous file opened + USER=www-data, a plain assignment that masks the inherited value, and + reading ${USER:-www-data} instead would take root's login environment and + hand every wp-content runtime directory to root, so uploads and plugin + installs would fail from the first supervised update onwards. Both are + asserted. + + * That is what let both scripts be measured: they were at no coverage at + all and are now at 100 percent of their lines, every branch driven, in + tests/wrappers.bats (COVERAGE.md). + + * conf.d/main, README.rst and tests/v19.sh call the Keel names. tests/v19.sh + also asks the compatibility names for the same answers, because a link + that resolves is not a link that works. + + -- Keel Linux maintainers Mon, 28 Sep 2026 03:00:00 +0000 + turnkey-wordpress-19.0 (3) turnkey; urgency=low * The build verifies the project's own APT archive instead of reading it diff --git a/conf.d/main b/conf.d/main index e91f3ea..f55cb11 100755 --- a/conf.d/main +++ b/conf.d/main @@ -59,7 +59,7 @@ sed -i "s|^allow_url_fopen.*|allow_url_fopen = On|" /etc/php/?.?/apache2/php.ini grep -q '^allow_url_fopen = On$' /etc/php/?.?/apache2/php.ini # wp-cli, pinned and verified. The first boot hook is the only thing that uses -# it unattended, and a person uses it afterwards through turnkey-wp. +# it unattended, and a person uses it afterwards through keel-wp. curl --proto '=https' --tlsv1.2 -fsSL \ "https://github.com/wp-cli/wp-cli/releases/download/v${WP_CLI_VERSION}/wp-cli-${WP_CLI_VERSION}.phar" \ -o /usr/local/bin/wp @@ -91,7 +91,7 @@ chown -R "$WEB_USER:$WEB_USER" "$WPROOT" # WordPress's own per file checksums, asked of WordPress. This is the second, # independent check on the same bytes: the digest above says the archive is the # one we pinned, this says the files in it are the ones WordPress published. -turnkey-wp core verify-checksums --version="$WP_VERSION" +keel-wp core verify-checksums --version="$WP_VERSION" # No wp-config.php, and none is written here. Assert it, because the whole # point of this recipe is that the layer carries no credential. diff --git a/overlay/usr/local/bin/keel-wp b/overlay/usr/local/bin/keel-wp new file mode 100755 index 0000000..c913c5c --- /dev/null +++ b/overlay/usr/local/bin/keel-wp @@ -0,0 +1,22 @@ +#!/bin/bash -e +# wp-cli against this appliance's WordPress, run as the web server's own user +# and never as root, with the path given explicitly so the command works from +# any directory. +# +# The Keel name is the real command; `turnkey-wp` beside it is a symlink to +# this file (decision 0015 of the handbook), so a script written against the +# old name, or a command pasted out of TurnKey's documentation, keeps working. + +[[ -z "$DEBUG" ]] || set -x + +WP_DIR=${WP_DIR:-/var/www/wordpress} +WP_USR=${WP_USR:-www-data} +WP_CACHE=${WP_CACHE:-/var/www/.wp-cli} + +if [[ ! -d "$WP_CACHE" ]]; then + mkdir -p "$WP_CACHE" +fi +chown -R "$WP_USR":"$WP_USR" "$WP_CACHE" + +runuser "$WP_USR" -s /bin/bash \ + -c "/usr/local/bin/wp --path='$WP_DIR' $(printf '%q ' "$@")" diff --git a/overlay/usr/local/bin/turnkey-wp b/overlay/usr/local/bin/turnkey-wp deleted file mode 100755 index 1b3bfd1..0000000 --- a/overlay/usr/local/bin/turnkey-wp +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -e - -[[ -z "$DEBUG" ]] || set -x - -WP_DIR=${WP_DIR:-/var/www/wordpress} -WP_USR=${WP_USR:-www-data} -WP_CACHE=${WP_CACHE:-/var/www/.wp-cli} - -if [[ ! -d "$WP_CACHE" ]]; then - mkdir -p "$WP_CACHE" -fi -chown -R "$WP_USR":"$WP_USR" "$WP_CACHE" - -runuser "$WP_USR" -s /bin/bash \ - -c "/usr/local/bin/wp --path='$WP_DIR' $(printf '%q ' "$@")" diff --git a/overlay/usr/local/bin/turnkey-wp b/overlay/usr/local/bin/turnkey-wp new file mode 120000 index 0000000..57f8fe9 --- /dev/null +++ b/overlay/usr/local/bin/turnkey-wp @@ -0,0 +1 @@ +keel-wp \ No newline at end of file diff --git a/overlay/usr/local/sbin/keel-wordpress-update b/overlay/usr/local/sbin/keel-wordpress-update new file mode 100755 index 0000000..55ff46c --- /dev/null +++ b/overlay/usr/local/sbin/keel-wordpress-update @@ -0,0 +1,48 @@ +#!/bin/bash +# A supervised WordPress core update: update core, verify WordPress's own per +# file checksums, and put the appliance's ownership boundary back afterwards +# (README.rst). Automatic core updates are off in wp-config.php because an +# unsupervised update does not restore that boundary. +# +# The Keel name is the real command; `turnkey-wordpress-update` beside it is a +# symlink to this file (decision 0015 of the handbook). The refusals below name +# whichever of the two the operator typed. +set -euo pipefail + +# These three are constants of the appliance. They are readable from +# KEEL_TEST_* only so tests/wrappers.bats can drive this script against a +# scratch tree. The prefix is not decoration: this runs as root and rewrites +# the owner and the mode of every file under its target, so the name that +# chooses that target must not be one an operator could already be exporting +# for something else. WPROOT is exactly such a name -- conf.d/main of this +# repository uses it for this same path. +WPROOT="${KEEL_TEST_WPROOT:-/var/www/wordpress}" +WP_USER="${KEEL_TEST_WP_USER:-www-data}" +WP_CLI="${KEEL_TEST_WP_CLI:-/usr/local/bin/wp}" +me=${0##*/} + +test "$(id -u)" -eq 0 || { + echo "$me must run as root" >&2 + exit 1 +} + +# And the target is a WordPress before anything is rewritten, whatever chose +# the path. conf.d/main asserts this same file after unpacking the release. +test -f "$WPROOT/wp-includes/version.php" || { + echo "$me: $WPROOT is not a WordPress installation" >&2 + exit 1 +} + +"$WP_CLI" --allow-root --path="$WPROOT" core update +"$WP_CLI" --allow-root --path="$WPROOT" core verify-checksums + +chown -R root:root "$WPROOT" +find "$WPROOT" -type d -exec chmod 0755 {} + +find "$WPROOT" -type f -exec chmod 0644 {} + +for runtime_dir in uploads cache upgrade plugins themes; do + install -d -o "$WP_USER" -g "$WP_USER" -m 0755 \ + "$WPROOT/wp-content/$runtime_dir" + chown -R "$WP_USER:$WP_USER" "$WPROOT/wp-content/$runtime_dir" +done +chown root:"$WP_USER" "$WPROOT/wp-config.php" +chmod 0640 "$WPROOT/wp-config.php" diff --git a/overlay/usr/local/sbin/turnkey-wordpress-update b/overlay/usr/local/sbin/turnkey-wordpress-update deleted file mode 100755 index 5ca0167..0000000 --- a/overlay/usr/local/sbin/turnkey-wordpress-update +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -set -euo pipefail - -WPROOT=/var/www/wordpress -USER=www-data - -test "$(id -u)" -eq 0 || { - echo "turnkey-wordpress-update must run as root" >&2 - exit 1 -} - -/usr/local/bin/wp --allow-root --path="$WPROOT" core update -/usr/local/bin/wp --allow-root --path="$WPROOT" core verify-checksums - -chown -R root:root "$WPROOT" -find "$WPROOT" -type d -exec chmod 0755 {} + -find "$WPROOT" -type f -exec chmod 0644 {} + -for runtime_dir in uploads cache upgrade plugins themes; do - install -d -o "$USER" -g "$USER" -m 0755 \ - "$WPROOT/wp-content/$runtime_dir" - chown -R "$USER:$USER" "$WPROOT/wp-content/$runtime_dir" -done -chown root:"$USER" "$WPROOT/wp-config.php" -chmod 0640 "$WPROOT/wp-config.php" diff --git a/overlay/usr/local/sbin/turnkey-wordpress-update b/overlay/usr/local/sbin/turnkey-wordpress-update new file mode 120000 index 0000000..b64b729 --- /dev/null +++ b/overlay/usr/local/sbin/turnkey-wordpress-update @@ -0,0 +1 @@ +keel-wordpress-update \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index 244000d..8f1cb43 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,6 +30,15 @@ the spec. as `PATH` stubs that log every call, and a scratch `INITHOOKS_PATH` whose `lib` is a symlink to the real library, so kcov measures the file the appliance ships. +- `wrappers.bats`: unit tests of the two operator commands this overlay + writes, `overlay/usr/local/bin/keel-wp` and + `overlay/usr/local/sbin/keel-wordpress-update`, and of the `turnkey-wp` and + `turnkey-wordpress-update` symlinks beside them (decision 0015 of the + handbook). `runuser`, `chown`, `install` and `id` are `PATH` stubs that log + every call, and wp-cli is a stub named by `WP_CLI`, so no test needs root, a + web server or a network. The compatibility names are run rather than + inspected, once through a copy of the overlay made with `cp -TdR`, which is + what `fab-apply-overlay` executes. - `keel-archive.bats`: unit tests of `conf.d/zzz-keel-archive`, the last conf script, which enables the project's signed APT archive and refuses to unless the key is in the image and the build time source is gone. @@ -51,6 +60,7 @@ Debian packages `bats` (1.11) and `kcov` (43); no root: bats tests/wordpress.bats bats tests/hook.bats + bats tests/wrappers.bats bats tests/boot-test.bats bats tests/keel-archive.bats COVERAGE_THRESHOLD=95 tests/coverage.sh diff --git a/tests/coverage.sh b/tests/coverage.sh index 968b967..3b94271 100755 --- a/tests/coverage.sh +++ b/tests/coverage.sh @@ -1,8 +1,9 @@ #!/bin/bash # Line coverage of the shell this project writes, measured with kcov over # the bats suite (decision 0004). The measured files are the first boot -# library, the first boot hook itself, the logic of the boot test, the build -# time archive checks and the script that enables the signed archive; the bar of +# library, the first boot hook itself, the two operator commands the overlay +# ships, the logic of the boot test, the build time archive checks and the +# script that enables the signed archive; the bar of # decision 0003 applies to all of them. Exits 1 below the threshold, 2 when a # tool is missing. tests/boot-test.sh is the thin main that runs keel and LXC # as root and is exercised by the container run in test-appliance.yml, not @@ -24,7 +25,7 @@ done report="${COVERAGE_DIR:-$(mktemp -d)}" # The include pattern is the whitelist, so no exclude pattern is needed; an # exclude of /tests/ would drop tests/lib/boot-test-lib.sh with it. -kcov --include-pattern=/lib/wordpress.sh,/firstboot.d/40wordpress,/tests/lib/boot-test-lib.sh,/bin/keel-archive-check,/conf.d/zz-project-packages,/conf.d/zzz-keel-archive \ +kcov --include-pattern=/lib/wordpress.sh,/firstboot.d/40wordpress,/overlay/usr/local/bin/keel-wp,/overlay/usr/local/sbin/keel-wordpress-update,/tests/lib/boot-test-lib.sh,/bin/keel-archive-check,/conf.d/zz-project-packages,/conf.d/zzz-keel-archive \ "$report" bats "$here" json="$(find "$report" -mindepth 2 -maxdepth 2 -name coverage.json -not -path "*/kcov-merged/*" | head -1)" diff --git a/tests/v19.sh b/tests/v19.sh index 42a3692..c53ab62 100755 --- a/tests/v19.sh +++ b/tests/v19.sh @@ -19,11 +19,11 @@ if "$hook" --pass="$password" --email=admin@example.com \ exit 1 fi "$hook" --pass="$password" --email=admin@example.com --domain=localhost -test "$(turnkey-wp option get siteurl)" = https://localhost +test "$(keel-wp option get siteurl)" = https://localhost "$hook" --pass="$password" --email=admin@example.com --domain=http://localhost -test "$(turnkey-wp option get siteurl)" = http://localhost +test "$(keel-wp option get siteurl)" = http://localhost "$hook" --pass="$password" --email=admin@example.com --domain=https://localhost -test "$(turnkey-wp option get siteurl)" = https://localhost +test "$(keel-wp option get siteurl)" = https://localhost # Authenticate as the provisioned administrator. login_url=$base/wp-login.php @@ -37,21 +37,42 @@ curl "${curl_args[@]}" -L -b "$work/cookies" -c "$work/cookies" \ grep -Eq 'Dashboard|wp-admin-bar' "$work/dashboard.html" # Create meaningful state, then prove it and the authenticated session survive restart. -post_id=$(turnkey-wp post create --post_status=publish \ +post_id=$(keel-wp post create --post_status=publish \ --post_title='TurnKey v19 acceptance' \ --post_content='qa-wordpress-persistence' --porcelain) test -n "$post_id" -test "$(turnkey-wp post get "$post_id" --field=post_content)" = \ +test "$(keel-wp post get "$post_id" --field=post_content)" = \ qa-wordpress-persistence -before=$(turnkey-wp core version) -turnkey-wp core check-update --format=json >"$work/update.json" +before=$(keel-wp core version) +test -n "$before" +keel-wp core check-update --format=json >"$work/update.json" python3 -c 'import json,sys; assert isinstance(json.load(open(sys.argv[1])), list)' \ "$work/update.json" +test "$(keel-wp core version)" = "$before" +keel-wp core verify-checksums +if runuser -u www-data -- /usr/local/sbin/keel-wordpress-update; then + echo 'non-root WordPress updater unexpectedly succeeded' >&2 + exit 1 +fi + +# The compatibility names of decision 0015, exercised rather than inspected: a +# link that resolves is not a link that works. turnkey-wp is asked the same +# questions keel-wp was just asked and has to give the same answers, and the +# updater's root guard has to hold under the old name too. +# +# Each answer is compared against a literal the script already knows, never +# against a second command substitution: a command substitution in test's +# arguments does not trip errexit, so comparing two of them passes as +# test "" = "" when wp-cli is broken and both sides are empty. +test -L /usr/local/bin/turnkey-wp +test "$(readlink /usr/local/bin/turnkey-wp)" = keel-wp test "$(turnkey-wp core version)" = "$before" -turnkey-wp core verify-checksums +test "$(turnkey-wp option get siteurl)" = https://localhost +test -L /usr/local/sbin/turnkey-wordpress-update +test "$(readlink /usr/local/sbin/turnkey-wordpress-update)" = keel-wordpress-update if runuser -u www-data -- /usr/local/sbin/turnkey-wordpress-update; then - echo 'non-root WordPress updater unexpectedly succeeded' >&2 + echo 'non-root WordPress updater unexpectedly succeeded under the compatibility name' >&2 exit 1 fi @@ -67,14 +88,14 @@ done systemctl restart mariadb.service apache2.service systemctl --quiet is-active mariadb.service apache2.service -test "$(turnkey-wp post get "$post_id" --field=post_content)" = \ +test "$(keel-wp post get "$post_id" --field=post_content)" = \ qa-wordpress-persistence curl "${curl_args[@]}" -b "$work/cookies" "$admin_url" \ >"$work/dashboard-after-restart.html" grep -Eq 'Dashboard|wp-admin-bar' "$work/dashboard-after-restart.html" curl "${curl_args[@]}" "$base/?p=$post_id" >"$work/post-after-restart.html" grep -Fq 'qa-wordpress-persistence' "$work/post-after-restart.html" -turnkey-wp post delete "$post_id" --force >/dev/null +keel-wp post delete "$post_id" --force >/dev/null ! grep -F -- "$password" /var/log/inithooks.log for key in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY \ @@ -88,8 +109,8 @@ done cat >"$result" < "$CALLS" + + # A name no account on any machine has, so an assertion on it cannot be + # satisfied by whatever the test runner's own user happens to be. + WEB_USER=keel-test-web-user + + WPROOT="$S/wordpress" + mkdir -p "$WPROOT/wp-content" "$WPROOT/wp-includes" + # The updater refuses a target that is not a WordPress, and this is the + # file it looks for; conf.d/main asserts the same one after unpacking. + printf ' "$WPROOT/wp-includes/version.php" + : > "$WPROOT/wp-config.php" + : > "$WPROOT/index.php" + + STUBS="$S/bin" + mkdir -p "$STUBS" + _stub runuser + _stub chown + _stub install + # id: root unless a test says otherwise, so the updater's guard can be + # driven both ways without being root. + cat > "$STUBS/id" <> "$CALLS" +echo "\${KEEL_TEST_UID:-0}" +EOF + chmod +x "$STUBS/id" + # wp-cli: logs, and fails the one invocation a test names. + cat > "$STUBS/wp" <> "$CALLS" +[ "\${KEEL_TEST_WP_FAIL:-}" = "\$*" ] && exit "\${KEEL_TEST_WP_CODE:-1}" +exit 0 +EOF + chmod +x "$STUBS/wp" + + PATH="$STUBS:$PATH" + export PATH + # keel-wp's three knobs are the ones it already shipped with and are the + # appliance's own. The updater's are KEEL_TEST_ prefixed because they exist + # only for these tests: that script runs as root and rewrites the owner and + # the mode of every file under its target, so the name that chooses the + # target must not be one an operator could already have exported. + export WP_DIR="$WPROOT" WP_USR="$WEB_USER" WP_CACHE="$S/wp-cli-cache" + export KEEL_TEST_WPROOT="$WPROOT" KEEL_TEST_WP_USER="$WEB_USER" + export KEEL_TEST_WP_CLI="$STUBS/wp" +} + +_stub() { + cat > "$STUBS/$1" <> "$CALLS" +exit \${KEEL_TEST_${1^^}_CODE:-0} +EOF + chmod +x "$STUBS/$1" +} + +# _runuser_command: the string keel-wp handed to runuser, which is the whole +# of what it asked the web user to run. +_runuser_command() { + sed -n 's/^runuser [^ ]* -s [^ ]* -c //p' "$CALLS" +} + +# --- keel-wp: the wrapper an operator types ---------------------------------- + +@test "keel-wp is the real command and turnkey-wp is a relative symlink to it" { + [ -f "$WP" ] && [ ! -L "$WP" ] + [ -x "$WP" ] + [ -L "$BIN/turnkey-wp" ] + # Relative, not absolute: an absolute link does not resolve inside + # fab-chroot, so conf.d/main would not find it at build time. + [ "$(readlink "$BIN/turnkey-wp")" = keel-wp ] +} + +@test "keel-wordpress-update is the real command and the turnkey name links to it" { + [ -f "$UPDATE" ] && [ ! -L "$UPDATE" ] + [ -x "$UPDATE" ] + [ -L "$SBIN/turnkey-wordpress-update" ] + [ "$(readlink "$SBIN/turnkey-wordpress-update")" = keel-wordpress-update ] +} + +@test "keel-wp runs wp-cli as the web user, with an explicit path, never as root" { + run "$WP" option get siteurl + [ "$status" -eq 0 ] + grep -q "^runuser $WEB_USER -s /bin/bash -c " "$CALLS" + [[ "$(_runuser_command)" == *"/usr/local/bin/wp --path='$WPROOT'"* ]] + [[ "$(_runuser_command)" != *--allow-root* ]] +} + +@test "keel-wp passes its arguments through to wp-cli" { + run "$WP" option get siteurl + [ "$status" -eq 0 ] + [[ "$(_runuser_command)" == *"option get siteurl"* ]] +} + +@test "keel-wp quotes an argument that carries a space" { + run "$WP" post create --post_title='TurnKey v19 acceptance' --porcelain + [ "$status" -eq 0 ] + [[ "$(_runuser_command)" == *"--post_title=TurnKey\\ v19\\ acceptance"* ]] +} + +@test "keel-wp quotes an argument that would otherwise end the command" { + run "$WP" eval "echo 'x'; rm -rf /" + [ "$status" -eq 0 ] + [[ "$(_runuser_command)" != *"; rm -rf /"* ]] +} + +@test "keel-wp creates the wp-cli cache directory when it is not there" { + [ ! -d "$WP_CACHE" ] + run "$WP" core version + [ "$status" -eq 0 ] + [ -d "$WP_CACHE" ] + grep -q "^chown -R $WEB_USER:$WEB_USER $WP_CACHE\$" "$CALLS" +} + +@test "keel-wp leaves an existing cache directory alone and still owns it" { + mkdir -p "$WP_CACHE" + : > "$WP_CACHE/already-here" + run "$WP" core version + [ "$status" -eq 0 ] + [ -f "$WP_CACHE/already-here" ] + grep -q "^chown -R $WEB_USER:$WEB_USER $WP_CACHE\$" "$CALLS" +} + +@test "keel-wp hands back the exit code runuser gave it" { + KEEL_TEST_RUNUSER_CODE=3 run "$WP" core verify-checksums + [ "$status" -eq 3 ] +} + +@test "keel-wp fails when the cache cannot be owned" { + KEEL_TEST_CHOWN_CODE=1 run "$WP" core version + [ "$status" -eq 1 ] + grep -q '^chown ' "$CALLS" + [ -z "$(_runuser_command)" ] +} + +@test "DEBUG makes keel-wp trace the command it runs" { + # kcov measures bash by turning xtrace on itself: it points BASH_ENV at a + # helper that sets its own PS4 and BASH_XTRACEFD, so under coverage the + # script's own trace goes to kcov's reader and never to this test. This + # one run is therefore left uninstrumented, with the ordinary prefix and + # stderr to trace to. Every line it executes is covered by the tests + # above, so nothing is lost from the measurement. + run env -u BASH_ENV DEBUG=1 PS4='+ ' BASH_XTRACEFD=2 "$WP" core version + [ "$status" -eq 0 ] + [[ "$output" == *"+ runuser"* ]] +} + +# --- the compatibility names, run rather than inspected ---------------------- + +@test "turnkey-wp runs the same command keel-wp does" { + [ -L "$BIN/turnkey-wp" ] + run "$BIN/turnkey-wp" option get siteurl + [ "$status" -eq 0 ] + grep -q "^runuser $WEB_USER -s /bin/bash -c " "$CALLS" + [[ "$(_runuser_command)" == *"--path='$WPROOT'"* ]] + [[ "$(_runuser_command)" == *"option get siteurl"* ]] +} + +@test "the overlay copy the build makes keeps turnkey-wp a working link" { + # cp -TdR is exactly what fab-apply-overlay runs (cmd_apply_overlay in + # fab), so this is the build's own copy step and not an imitation of it. + # Twice, because the Makefile applies this overlay twice: once through + # COMMON_OVERLAYS and once as the product-local ROOT_OVERLAY. A second + # copy over an existing link must leave a link and not a copy of its + # target. + root="$S/root.patched" + mkdir -p "$root" + cp -TdR "$REPO/overlay" "$root" + cp -TdR "$REPO/overlay" "$root" + [ -L "$root/usr/local/bin/turnkey-wp" ] + [ "$(readlink "$root/usr/local/bin/turnkey-wp")" = keel-wp ] + [ -x "$root/usr/local/bin/keel-wp" ] + run "$root/usr/local/bin/turnkey-wp" option get siteurl + [ "$status" -eq 0 ] + [[ "$(_runuser_command)" == *"option get siteurl"* ]] +} + +@test "the overlay copy keeps turnkey-wordpress-update a working link" { + root="$S/root.patched" + mkdir -p "$root" + cp -TdR "$REPO/overlay" "$root" + cp -TdR "$REPO/overlay" "$root" + [ -L "$root/usr/local/sbin/turnkey-wordpress-update" ] + run "$root/usr/local/sbin/turnkey-wordpress-update" + [ "$status" -eq 0 ] + grep -q "^wp --allow-root --path=$WPROOT core update\$" "$CALLS" +} + +@test "it is -L that would flatten the link, and -d is not what prevents it" { + # What the overlay step has to avoid is dereferencing. -P is already the + # default under -R, so a plain -R keeps the link, and -d only adds + # --preserve=links, which is about hard links and does nothing here. That + # is asserted rather than left in a comment, because the comment above + # says the build's flags are safe and this is the reason they are. + plain="$S/plain"; deref="$S/deref" + cp -TR "$REPO/overlay" "$plain" + cp -TLR "$REPO/overlay" "$deref" + # -R without -d: still a link, and still a working command. + [ -L "$plain/usr/local/bin/turnkey-wp" ] + run "$plain/usr/local/bin/turnkey-wp" core version + [ "$status" -eq 0 ] + # -L: a second regular file, and the compatibility name stops being a link + # to anything. This is the flag that would break the property. + [ ! -L "$deref/usr/local/bin/turnkey-wp" ] + [ -f "$deref/usr/local/bin/turnkey-wp" ] + [ ! -L "$deref/usr/local/sbin/turnkey-wordpress-update" ] +} + +@test "turnkey-wordpress-update refuses a non-root caller under its own name" { + [ -L "$SBIN/turnkey-wordpress-update" ] + KEEL_TEST_UID=1000 run "$SBIN/turnkey-wordpress-update" + [ "$status" -eq 1 ] + [[ "$output" == *"turnkey-wordpress-update must run as root"* ]] +} + +# --- keel-wordpress-update: the supervised core update ----------------------- + +@test "keel-wordpress-update refuses a caller who is not root" { + KEEL_TEST_UID=1000 run "$UPDATE" + [ "$status" -eq 1 ] + [[ "$output" == *"keel-wordpress-update must run as root"* ]] + ! grep -q '^wp ' "$CALLS" +} + +@test "keel-wordpress-update refuses a target that is not a WordPress" { + rm -f "$WPROOT/wp-includes/version.php" + run "$UPDATE" + [ "$status" -eq 1 ] + [[ "$output" == *"is not a WordPress installation"* ]] + ! grep -q '^wp ' "$CALLS" + ! grep -q '^chown ' "$CALLS" +} + +@test "keel-wordpress-update updates core and then verifies the checksums" { + run "$UPDATE" + [ "$status" -eq 0 ] + grep -q "^wp --allow-root --path=$WPROOT core update\$" "$CALLS" + grep -q "^wp --allow-root --path=$WPROOT core verify-checksums\$" "$CALLS" +} + +@test "keel-wordpress-update stops when the update fails" { + KEEL_TEST_WP_FAIL="--allow-root --path=$WPROOT core update" \ + run "$UPDATE" + [ "$status" -eq 1 ] + grep -q "^wp --allow-root --path=$WPROOT core update\$" "$CALLS" + ! grep -q 'verify-checksums' "$CALLS" +} + +@test "keel-wordpress-update stops when the checksums do not verify" { + KEEL_TEST_WP_FAIL="--allow-root --path=$WPROOT core verify-checksums" \ + run "$UPDATE" + [ "$status" -eq 1 ] + grep -q "^wp --allow-root --path=$WPROOT core verify-checksums\$" "$CALLS" + ! grep -q '^chown -R root:root' "$CALLS" +} + +@test "keel-wordpress-update puts the ownership boundary back" { + run "$UPDATE" + [ "$status" -eq 0 ] + grep -q "^chown -R root:root $WPROOT\$" "$CALLS" + grep -q "^chown root:$WEB_USER $WPROOT/wp-config.php\$" "$CALLS" + [ "$(stat -c %a "$WPROOT/wp-config.php")" = 640 ] + [ "$(stat -c %a "$WPROOT/index.php")" = 644 ] + [ "$(stat -c %a "$WPROOT")" = 755 ] +} + +@test "keel-wordpress-update leaves the five runtime directories to the web server" { + run "$UPDATE" + [ "$status" -eq 0 ] + for dir in uploads cache upgrade plugins themes; do + grep -q "^install -d -o $WEB_USER -g $WEB_USER -m 0755 $WPROOT/wp-content/$dir\$" \ + "$CALLS" + grep -q "^chown -R $WEB_USER:$WEB_USER $WPROOT/wp-content/$dir\$" "$CALLS" + done +} + +# --- the two names that must not come from the caller's environment ---------- + +@test "USER in the environment does not decide who owns wp-content" { + # The file this replaced opened `USER=www-data`, a plain assignment that + # masks the inherited value. Reading `${USER:-www-data}` instead would take + # root's login environment, where USER is root, and hand every runtime + # directory to root:root: uploads and plugin installs would fail from the + # first supervised update onwards, with nothing pointing back at it. + USER=root run "$UPDATE" + [ "$status" -eq 0 ] + grep -q "^chown -R $WEB_USER:$WEB_USER $WPROOT/wp-content/uploads\$" "$CALLS" + ! grep -q '^chown -R root:root .*wp-content' "$CALLS" + grep -q "^chown root:$WEB_USER $WPROOT/wp-config.php\$" "$CALLS" +} + +@test "WPROOT in the environment does not decide what this script rewrites" { + # It runs as root and rewrites the owner and the mode of every file under + # its target, so the variable that chooses that target is deliberately not + # a name an operator could already be exporting. conf.d/main of this very + # repository uses WPROOT for this same path. + WPROOT=/ WP_USER=root WP_CLI=/bin/true run "$UPDATE" + [ "$status" -eq 0 ] + grep -q "^chown -R root:root $KEEL_TEST_WPROOT\$" "$CALLS" + ! grep -qx 'chown -R root:root /' "$CALLS" + grep -q "^wp --allow-root --path=$KEEL_TEST_WPROOT core update\$" "$CALLS" +}