#!/usr/bin/env bash # Tests for the installer's Node provisioning. # # The installer's whole promise is that one command turns a bare host into a working # one. Node was the exception: it was a hard prerequisite the installer checked and # refused, so on a greenfield host the documented one-command install failed first. # These tests pin the fixed behaviour, including the refusals. # # Everything runs offline. MOSAIC_NODE_DIST points at a local directory laid out like # nodejs.org/dist, served over file:// -- so the download, the checksum gate, and the # unpack are the real code paths, with no network and no real Node download. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-provision-test-XXXXXX")" trap 'rm -rf "$TMP"' EXIT DIST="$TMP/dist" FAKE_BIN="$TMP/bin" HOME_DIR="$TMP/home" PREFIX="$TMP/prefix" MOSAIC_HOME_DIR="$TMP/mosaic" STATE="$TMP/state" LOG="$TMP/npm.log" NODE_HOME="$TMP/nodehome" mkdir -p "$DIST" "$FAKE_BIN" "$HOME_DIR" "$STATE" REAL_NODE="$(command -v node)" # The platform triple, derived the same way the installer derives it. case "$(uname -s)" in Linux) TEST_OS=linux ;; Darwin) TEST_OS=darwin ;; *) echo "[skip] no Node build for $(uname -s)"; exit 0 ;; esac case "$(uname -m)" in x86_64|amd64) TEST_ARCH=x64 ;; aarch64|arm64) TEST_ARCH=arm64 ;; armv7l) TEST_ARCH=armv7l ;; *) echo "[skip] no Node build for $(uname -m)"; exit 0 ;; esac PLATFORM="${TEST_OS}-${TEST_ARCH}" VERSION=v22.99.0 # the one that must be chosen MID_VERSION=v22.50.0 # same major, older -- catches "take the last match" OLD_VERSION=v20.99.0 # wrong major NEWER_MAJOR=v24.99.0 # listed first -- catches "take the first entry" # ─── fixtures ───────────────────────────────────────────────────────────────── # A node stub that answers the installer's version probe and defers everything else # to the real interpreter, so the rest of the install still runs. # # The major is baked in per stub rather than read from the environment. A shared env # var would be read by the downloaded Node too, so the "system Node is too old" case # would install a replacement that also claimed to be too old. write_node_stub() { local path="$1" major="${2:-22}" cat > "$path" < "$1" <<'STUB' #!/usr/bin/env bash set -euo pipefail echo "$*" >> "${MOSAIC_TEST_NPM_LOG:?}" STATE="${MOSAIC_TEST_STATE:?}" if [[ "${1:-}" == "view" ]]; then case "$2 $3" in "@mosaicstack/mosaic@next version") echo "0.0.50-next.999" ;; "@mosaicstack/gateway@next version") echo "0.0.7-next.999" ;; "@mosaicstack/mosaic version") echo "0.0.49" ;; *) echo "unexpected npm view: $*" >&2; exit 1 ;; esac exit 0 fi if [[ "${1:-}" == "install" ]]; then case "$*" in *"@mosaicstack/mosaic@"*) echo "0.0.50-next.999" > "$STATE/mosaic" ;; *"@mosaicstack/gateway@"*) echo "0.0.7-next.999" > "$STATE/gateway" ;; esac exit 0 fi if [[ "${1:-}" == "ls" ]]; then printf '{"dependencies":{"@mosaicstack/mosaic":{"version":"%s"},"@mosaicstack/gateway":{"version":"%s"}}}\n' \ "$(cat "$STATE/mosaic" 2>/dev/null || echo '')" \ "$(cat "$STATE/gateway" 2>/dev/null || echo '')" exit 0 fi exit 0 STUB chmod +x "$1" } # Build a nodejs.org-shaped release: the tarball, and a SHASUMS256.txt over it. publish_release() { local version="$1" corrupt_checksum="${2:-false}" local base="node-${version}-${PLATFORM}" local stage="$TMP/stage-${version}" rm -rf "$stage" mkdir -p "$stage/${base}/bin" write_node_stub "$stage/${base}/bin/node" "$(sed 's/^v//; s/\..*//' <<<"$version")" write_npm_stub "$stage/${base}/bin/npm" mkdir -p "${DIST}/${version}" tar -czf "${DIST}/${version}/${base}.tar.gz" -C "$stage" "$base" local sum if command -v sha256sum &>/dev/null; then sum="$(sha256sum "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')" else sum="$(shasum -a 256 "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')" fi if [[ "$corrupt_checksum" == "true" ]]; then sum="0000000000000000000000000000000000000000000000000000000000000000" fi printf '%s %s.tar.gz\n' "$sum" "$base" > "${DIST}/${version}/SHASUMS256.txt" } publish_release "$VERSION" publish_release "$MID_VERSION" publish_release "$OLD_VERSION" publish_release "$NEWER_MAJOR" # Newest-first, as nodejs.org publishes it. Every wrong entry is genuinely installable, # so a resolver that picks one fails on the assertion rather than on a 404 -- the # assertion is then about version selection and not about the fixture. printf '[{"version":"%s"},{"version":"%s"},{"version":"%s"},{"version":"%s"}]\n' \ "$NEWER_MAJOR" "$VERSION" "$MID_VERSION" "$OLD_VERSION" > "$DIST/index.json" # A PATH with the usual tools but no Node toolchain, so "a host with no Node" is # actually true on a developer machine and in CI, both of which have one installed. NONODE_BIN="$TMP/nonode-bin" mkdir -p "$NONODE_BIN" for candidate in /usr/bin/* /bin/*; do [[ -e "$candidate" ]] || continue case "$(basename "$candidate")" in node|npm|npx|corepack|nodejs) continue ;; esac ln -sf "$candidate" "$NONODE_BIN/$(basename "$candidate")" 2>/dev/null || true done if PATH="$NONODE_BIN" command -v node &>/dev/null; then echo "[skip] could not build a Node-free PATH on this host" >&2 exit 0 fi reset_home() { rm -rf "$HOME_DIR" "$PREFIX" "$MOSAIC_HOME_DIR" "$NODE_HOME" "$LOG" "$STATE" mkdir -p "$HOME_DIR" "$STATE" : > "$LOG" } # Run the installer with no Node anywhere on PATH. run_bare() { env -u npm_config_prefix \ HOME="$HOME_DIR" \ MOSAIC_HOME="$MOSAIC_HOME_DIR" \ MOSAIC_PREFIX="$PREFIX" \ MOSAIC_NO_COLOR=1 \ MOSAIC_NODE_HOME="$NODE_HOME" \ MOSAIC_NODE_DIST="file://${DIST}" \ MOSAIC_TEST_REAL_NODE="$REAL_NODE" \ MOSAIC_TEST_NPM_LOG="$LOG" \ MOSAIC_TEST_STATE="$STATE" \ PATH="$NONODE_BIN" \ bash "$ROOT/tools/install.sh" "$@" } # ─── tests ──────────────────────────────────────────────────────────────────── reset_home echo "[test] a host with no Node gets one, and the CLI install proceeds" OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)" grep -qF -- "Node is not installed" <<<"$OUTPUT" grep -qF -- "Installed Node ${VERSION}" <<<"$OUTPUT" [[ -x "${NODE_HOME}/${VERSION}/bin/node" ]] grep -qF -- "install -g @mosaicstack/mosaic@0.0.50-next.999" "$LOG" echo "[test] the newest release of the required major is chosen" # The index lists a higher major first and an older release of the right major after # the right answer, so "first entry" and "last match" both produce a wrong directory. [[ -d "${NODE_HOME}/${VERSION}" ]] [[ ! -d "${NODE_HOME}/${NEWER_MAJOR}" ]] [[ ! -d "${NODE_HOME}/${MID_VERSION}" ]] [[ ! -d "${NODE_HOME}/${OLD_VERSION}" ]] echo "[test] future shells can find both Node and the CLI" grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.profile" grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.profile" # Debian's .bashrc returns early when non-interactive, so the login profile is the # one that matters -- but an interactive non-login shell only reads .bashrc. grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.bashrc" grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.bashrc" echo "[test] a real login shell resolves node, not just the text of a profile line" # Grepping the file only proves the installer wrote something. This starts an actual # login shell against that HOME and asks it to find the binary. RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')" [[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || { echo "a login shell resolved node to '${RESOLVED}'" >&2 exit 1 } echo "[test] a systemd --user unit gets the same PATH, via environment.d" # Units read no shell file at all, which is how a Mosaic agent seat starts. ENVD="$HOME_DIR/.config/environment.d/50-mosaic-path.conf" [[ -f "$ENVD" ]] || { echo "no environment.d drop-in was written" >&2; exit 1; } grep -qF -- "PATH=${NODE_HOME}/${VERSION}/bin:\${PATH}" "$ENVD" grep -qF -- "PATH=${PREFIX}/bin:\${PATH}" "$ENVD" echo "[test] re-running reuses the Node it installed and does not duplicate PATH lines" OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)" grep -qF -- "from ${NODE_HOME}" <<<"$OUTPUT" [[ "$(grep -c 'export PATH=' "$HOME_DIR/.profile")" -eq 2 ]] [[ "$(grep -c 'export PATH=' "$HOME_DIR/.bashrc")" -eq 2 ]] [[ "$(grep -c '^PATH=' "$ENVD")" -eq 2 ]] reset_home echo "[test] a ~/.bash_profile does not silently swallow the PATH entry" # A bash login shell reads the first of .bash_profile / .bash_login / .profile that # exists and never looks at the rest. Writing only .profile is a no-op on such a host, # and the failure is invisible until something cannot find node. : > "$HOME_DIR/.bash_profile" run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1 RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')" [[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || { echo "with a .bash_profile present, a login shell resolved node to '${RESOLVED}'" >&2 exit 1 } reset_home echo "[test] a commented-out example does not count as the PATH entry already existing" # The idempotence check used to be an unanchored substring match, so a line like this # in a user's profile made the installer skip the real entry. mkdir -p "$HOME_DIR" printf '# export PATH="%s/%s/bin:$PATH"\n' "$NODE_HOME" "$VERSION" > "$HOME_DIR/.profile" run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1 [[ "$(grep -c '^export PATH=' "$HOME_DIR/.profile")" -eq 2 ]] || { echo "expected two real export lines, found:" >&2 cat "$HOME_DIR/.profile" >&2 exit 1 } reset_home echo "[test] --no-node-install refuses instead of installing" set +e OUTPUT="$(run_bare --cli --next --yes --no-node-install 2>&1)" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "--no-node-install was given" <<<"$OUTPUT" [[ ! -d "$NODE_HOME" ]] reset_home echo "[test] --check never provisions Node" set +e OUTPUT="$(run_bare --check --cli --next 2>&1)" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "Required command not found: node" <<<"$OUTPUT" [[ ! -d "$NODE_HOME" ]] reset_home echo "[test] a tampered download is rejected and nothing is installed" publish_release "$VERSION" true set +e OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "failed checksum verification" <<<"$OUTPUT" # Not just "no usable node": nothing at all may survive. An unpack that ran before # verification, or a staging directory left behind, would still satisfy the weaker # check while leaving unverified bytes on disk for the next run to adopt. [[ ! -x "${NODE_HOME}/${VERSION}/bin/node" ]] [[ ! -e "${NODE_HOME}/${VERSION}" ]] [[ ! -e "${NODE_HOME}/${VERSION}.partial" ]] [[ ! -d "$NODE_HOME" ]] || [[ -z "$(ls -A "$NODE_HOME")" ]] publish_release "$VERSION" reset_home echo "[test] a system Node that is new enough is used as-is and left alone" write_node_stub "$FAKE_BIN/node" 22 write_npm_stub "$FAKE_BIN/npm" OUTPUT="$( env -u npm_config_prefix \ HOME="$HOME_DIR" \ MOSAIC_HOME="$MOSAIC_HOME_DIR" \ MOSAIC_PREFIX="$PREFIX" \ MOSAIC_NO_COLOR=1 \ MOSAIC_NODE_HOME="$NODE_HOME" \ MOSAIC_NODE_DIST="file://${DIST}" \ MOSAIC_TEST_REAL_NODE="$REAL_NODE" \ MOSAIC_TEST_NPM_LOG="$LOG" \ MOSAIC_TEST_STATE="$STATE" \ PATH="$FAKE_BIN:$NONODE_BIN" \ bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 )" grep -qF -- "satisfies the >= 22 requirement" <<<"$OUTPUT" [[ ! -d "$NODE_HOME" ]] reset_home echo "[test] a system Node that is too old is replaced rather than accepted" write_node_stub "$FAKE_BIN/node" 18 OUTPUT="$( env -u npm_config_prefix \ HOME="$HOME_DIR" \ MOSAIC_HOME="$MOSAIC_HOME_DIR" \ MOSAIC_PREFIX="$PREFIX" \ MOSAIC_NO_COLOR=1 \ MOSAIC_NODE_HOME="$NODE_HOME" \ MOSAIC_NODE_DIST="file://${DIST}" \ MOSAIC_TEST_REAL_NODE="$REAL_NODE" \ MOSAIC_TEST_NPM_LOG="$LOG" \ MOSAIC_TEST_STATE="$STATE" \ PATH="$FAKE_BIN:$NONODE_BIN" \ bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 )" grep -qF -- "older than the required >= 22" <<<"$OUTPUT" [[ -x "${NODE_HOME}/${VERSION}/bin/node" ]] # ─── refusals: untrusted input that reaches a path or an exec ───────────────── reset_home echo "[test] an empty checksum manifest is refused, not read as an empty digest" : > "${DIST}/${VERSION}/SHASUMS256.txt" set +e OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "No checksum published" <<<"$OUTPUT" [[ ! -e "${NODE_HOME}/${VERSION}" ]] publish_release "$VERSION" reset_home echo "[test] a manifest naming a regex-equivalent file does not vouch for this one" # The lookup used to interpolate the filename into a grep pattern. A Node tarball name # is mostly dots, and a dot matches any character, so this line -- which names a # different file -- was accepted as this file's checksum. DECOY="node-${VERSION}-${PLATFORM}Xtar.gz" printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$DECOY" > "${DIST}/${VERSION}/SHASUMS256.txt" set +e OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "No checksum published" <<<"$OUTPUT" [[ ! -e "${NODE_HOME}/${VERSION}" ]] publish_release "$VERSION" reset_home echo "[test] a manifest listing the same file twice is refused rather than guessed at" BASE="node-${VERSION}-${PLATFORM}.tar.gz" GOOD="$(awk '{print $1}' "${DIST}/${VERSION}/SHASUMS256.txt")" { printf '%s %s\n' "$GOOD" "$BASE" printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$BASE" } > "${DIST}/${VERSION}/SHASUMS256.txt" set +e OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "refusing to guess" <<<"$OUTPUT" [[ ! -e "${NODE_HOME}/${VERSION}" ]] publish_release "$VERSION" echo "[test] a version string is checked before it becomes a path" # MOSAIC_NODE_VERSION becomes a directory name under NODE_HOME, and that directory is # later handed to `rm -rf`. This is defence in depth, and the honest scope should be # recorded: the plain 'v..' case is separately refused by rm itself, and a traversal # value breaks the download URL before the removal is reached. Measured, not assumed. # What the check buys is that neither of those accidents is what is protecting us, and # that a typo is refused with its own name on it rather than a curl error. eval "$(sed -n '/^node_valid_version()/,/^}/p' "$ROOT/tools/install.sh")" for good in v22.99.0 v0.0.0 v22.11.0 v100.0.1; do node_valid_version "$good" || { echo "rejected a real version: ${good}" >&2; exit 1; } done for bad in 'v..' '..' 'v9.9.9/../../elsewhere' '/etc' 'v22' 'v22.1' '22.1.0' 'v22.1.0-rc1' '' 'v1.0.0 ' '$(id)'; do ! node_valid_version "$bad" || { echo "accepted a bad version: '${bad}'" >&2; exit 1; } done reset_home echo "[test] a bad MOSAIC_NODE_VERSION is refused by name, before any download" set +e OUTPUT="$( env -u npm_config_prefix \ HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \ MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \ MOSAIC_NODE_DIST="file://${DIST}" MOSAIC_NODE_VERSION="v9.9.9/../../elsewhere" \ MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \ MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \ bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 )" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "MOSAIC_NODE_VERSION" <<<"$OUTPUT" grep -qF -- "Downloading Node" <<<"$OUTPUT" && { echo "the download started despite an invalid version" >&2 exit 1 } [[ ! -d "$NODE_HOME" ]] reset_home echo "[test] a download location with no transport integrity is refused" set +e OUTPUT="$( env -u npm_config_prefix \ HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \ MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \ MOSAIC_NODE_DIST="http://example.invalid/dist" \ MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \ MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \ bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 )" RC=$? set -e [[ "$RC" -ne 0 ]] grep -qF -- "MOSAIC_NODE_DIST must be" <<<"$OUTPUT" [[ ! -d "$NODE_HOME" ]] reset_home echo "[test] a path containing shell syntax is not written into a profile" # The PATH line is executed by every future shell that reads the file, so a directory # holding $() or a quote would run there as code. EVIL="$TMP/ev\$(touch $TMP/pwned)il" set +e env -u npm_config_prefix \ HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$EVIL" \ MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \ MOSAIC_NODE_DIST="file://${DIST}" \ MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \ MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \ bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch >/dev/null 2>&1 set -e if [[ -f "$HOME_DIR/.profile" ]]; then grep -qF -- 'touch' "$HOME_DIR/.profile" && { echo "a command substitution was written into .profile" >&2 exit 1 } fi [[ ! -e "$TMP/pwned" ]] || { echo "the embedded command ran" >&2; exit 1; } echo "[test] installer node provisioning tests passed"