From 444470301c12487e54b7476316e6ec07ee7a5b76 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Wed, 22 Jul 2026 22:01:51 +0000 Subject: [PATCH] test: stubbed shell tests for the NodeSource bootstrap logic Covers the scenarios codex-reviewer recommended: suitable candidate already present (incl. epoch stripping), missing metadata healed by a refresh, bootstrap on too-old distro nodejs, bootstrap failure, and the refuse-to-overwrite branch for a user-managed nodesource.list. Every scenario runs under a localized LC_ALL with an apt-cache stub that only emits the English Candidate: label under LC_ALL=C, so locale-safe parsing is regression-tested (mutation-checked: dropping LC_ALL=C fails 3 tests). install-apt.sh gains STOKE_APT_ETC to redirect /etc/apt to a throwaway directory under test, following the script's existing env-override pattern. Real-container flow re-verified on debian:13. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/install-apt.sh | 13 ++-- test/install-apt.test.js | 133 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 test/install-apt.test.js diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index bf13ef5..4ac6efe 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -18,9 +18,12 @@ FORGE_URL="${FORGE_URL:-https://forgejo.heavyduty.builders}" OWNER="${OWNER:-heavy-duty}" DISTRIBUTION="${DISTRIBUTION:-stable}" COMPONENT="${COMPONENT:-main}" +# Where apt configuration lives; overridable so tests can run against a +# throwaway directory instead of the real /etc/apt. +APT_ETC="${STOKE_APT_ETC:-/etc/apt}" -KEYRING="/etc/apt/keyrings/forgejo-$OWNER.asc" -LIST="/etc/apt/sources.list.d/forgejo-$OWNER.list" +KEYRING="$APT_ETC/keyrings/forgejo-$OWNER.asc" +LIST="$APT_ETC/sources.list.d/forgejo-$OWNER.list" SUDO="" if [ "$(id -u)" -ne 0 ]; then @@ -57,8 +60,8 @@ ensure_nodejs_source() { echo "No apt source seems to provide nodejs >= $NODE_MIN; refreshing apt metadata ..." $SUDO apt-get update || true node_candidate_ok && return 0 - local ns_keyring="/etc/apt/keyrings/nodesource.asc" - local ns_list="/etc/apt/sources.list.d/nodesource.list" + local ns_keyring="$APT_ETC/keyrings/nodesource.asc" + local ns_list="$APT_ETC/sources.list.d/nodesource.list" if [ -e "$ns_list" ]; then echo "error: nodejs >= $NODE_MIN is unavailable and $ns_list already exists;" >&2 echo "refusing to overwrite it. Point it at a Node >= 22 release and re-run." >&2 @@ -73,7 +76,7 @@ ensure_nodejs_source() { } echo "Adding APT source for $FORGE_URL/$OWNER ..." -$SUDO install -d -m 0755 /etc/apt/keyrings +$SUDO install -d -m 0755 "$APT_ETC/keyrings" curl -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \ | $SUDO tee "$LIST" >/dev/null diff --git a/test/install-apt.test.js b/test/install-apt.test.js new file mode 100644 index 0000000..c78b7dc --- /dev/null +++ b/test/install-apt.test.js @@ -0,0 +1,133 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh'); + +// Runs install-apt.sh against a throwaway apt directory (STOKE_APT_ETC) with +// every external command stubbed via PATH. Scenario knobs: +// candInitial `apt-cache policy` Candidate before any update +// candAfterUpdate Candidate after any `apt-get update` +// candAfterNodesource Candidate after an update once nodesource.list exists +// The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set, +// so every scenario doubles as a regression test for locale-safe parsing. +function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-')); + const bin = path.join(root, 'bin'); + const state = path.join(root, 'state'); + const aptEtc = path.join(root, 'etc', 'apt'); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(state, { recursive: true }); + fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true }); + fs.writeFileSync(path.join(state, 'candidate'), candInitial); + if (preexistingNodesourceList !== undefined) { + fs.writeFileSync(path.join(aptEtc, 'sources.list.d', 'nodesource.list'), preexistingNodesourceList); + } + + const stub = (name, body) => { + const p = path.join(bin, name); + fs.writeFileSync(p, `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 }); + }; + + // Force the non-root path so every mutation goes through the sudo stub. + stub('id', 'echo 1000'); + stub('sudo', 'exec "$@"'); + stub('curl', 'echo "FAKE-KEY"'); + stub('stoke', 'echo 1.2.0'); + stub('apt-cache', [ + 'cand="$(cat "$STATE_DIR/candidate")"', + '[ "$cand" = "absent" ] && exit 0', + 'label="Candidato"', + '[ "${LC_ALL:-}" = "C" ] && label="Candidate"', + 'printf "nodejs:\\n Installed: (none)\\n %s: %s\\n" "$label" "$cand"', + ].join('\n')); + stub('apt-get', [ + 'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"', + 'for a in "$@"; do', + ' if [ "$a" = update ]; then', + ' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then', + ' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"', + ' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then', + ' echo "$CAND_AFTER_UPDATE" > "$STATE_DIR/candidate"', + ' fi', + ' fi', + 'done', + 'exit 0', + ].join('\n')); + + const res = spawnSync('bash', [SCRIPT], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + STOKE_APT_ETC: aptEtc, + STATE_DIR: state, + CAND_AFTER_UPDATE: candAfterUpdate || '', + CAND_AFTER_NODESOURCE: candAfterNodesource || '', + LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C + }, + }); + + const read = (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null); + return { + res, + aptEtc, + nodesourceList: read(path.join(aptEtc, 'sources.list.d', 'nodesource.list')), + nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')), + aptGetLog: read(path.join(state, 'apt-get.log')) || '', + }; +} + +test('suitable nodejs candidate already available: installs without touching NodeSource', () => { + // Epoch-prefixed version also covers the epoch-stripping in the comparison. + const s = runScenario({ candInitial: '1:22.23.1-1nodesource1' }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.equal(s.nodesourceList, null); + assert.match(s.aptGetLog, /install -y stoke/); +}); + +test('no cached metadata: refreshes apt lists before deciding, no NodeSource needed', () => { + const s = runScenario({ candInitial: 'absent', candAfterUpdate: '22.23.1-1nodesource1' }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.equal(s.nodesourceList, null); + assert.match(s.aptGetLog, /install -y stoke/); +}); + +test('distro nodejs too old: bootstraps NodeSource and installs', () => { + const s = runScenario({ + candInitial: '20.19.2+dfsg-1+deb13u2', + candAfterUpdate: '20.19.2+dfsg-1+deb13u2', + candAfterNodesource: '22.23.1-1nodesource1', + }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.match(s.nodesourceList, /deb \[signed-by=.*nodesource\.asc\] https:\/\/deb\.nodesource\.com\/node_22\.x nodistro main/); + assert.equal(s.nodesourceKey, 'FAKE-KEY\n'); + assert.match(s.aptGetLog, /install -y stoke/); +}); + +test('bootstrap failure: NodeSource still lacks a suitable nodejs, exits with error', () => { + const s = runScenario({ + candInitial: '20.19.2+dfsg-1+deb13u2', + candAfterUpdate: '20.19.2+dfsg-1+deb13u2', + candAfterNodesource: '20.19.2+dfsg-1+deb13u2', + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, /still no nodejs >= 22\.12/); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +}); + +test('pre-existing user-managed nodesource.list is never overwritten', () => { + const marker = '# user-managed entry\n'; + const s = runScenario({ + candInitial: '18.19.1+dfsg-6ubuntu5', + candAfterUpdate: '18.19.1+dfsg-6ubuntu5', + preexistingNodesourceList: marker, + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, /refusing to overwrite/); + assert.equal(s.nodesourceList, marker); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +});