2026-07-22 22:01:51 +00:00
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
2026-07-26 21:41:16 +00:00
// releaseStatus HTTP status curl reports for the registry Release file
2026-08-30 11:31:24 +00:00
// sourceUpdateError stderr and exit 100 for the first signed stoke update
2026-08-30 11:34:06 +00:00
// forgeUser/token private-registry credentials
2026-08-31 10:48:42 +00:00
// allowUnverified explicit HTTPS-only integrity opt-in
2026-07-22 22:01:51 +00:00
// 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.
2026-07-22 23:17:20 +00:00
const cleanups = [ ] ;
process . on ( 'exit' , ( ) => { for ( const dir of cleanups ) fs . rmSync ( dir , { recursive : true , force : true } ) ; } ) ;
2026-08-31 10:48:42 +00:00
function runScenario ( { candInitial , candAfterUpdate , candAfterNodesource , preexistingNodesourceList , releaseStatus , sourceUpdateError , forgeUser , forgeToken , allowUnverified } ) {
2026-07-22 22:01:51 +00:00
const root = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'stoke-apt-test-' ) ) ;
2026-07-22 23:17:20 +00:00
cleanups . push ( root ) ;
2026-07-22 22:01:51 +00:00
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' ) ;
2026-08-30 11:40:41 +00:00
stub ( 'sudo' , 'SUDO_ACTIVE=1 exec "$@"' ) ;
2026-07-26 21:41:16 +00:00
// Registry Release-file probes (URLs under /dists/) answer with the
// scenario's HTTP status; everything else is a key fetch.
stub ( 'curl' , [
2026-08-30 11:40:41 +00:00
'uses_netrc=false' ,
'for a in "$@"; do [ "$a" = "--netrc-file" ] && uses_netrc=true; done' ,
'if [ "$uses_netrc" = true ] && [ "${SUDO_ACTIVE:-}" != 1 ]; then' ,
' echo "curl: root-owned netrc is unreadable without sudo" >&2' ,
' exit 77' ,
'fi' ,
2026-07-26 21:41:16 +00:00
'for a in "$@"; do' ,
' case "$a" in */dists/*) echo "${RELEASE_STATUS:-200}"; exit 0;; esac' ,
'done' ,
'echo "FAKE-KEY"' ,
] . join ( '\n' ) ) ;
2026-07-22 22:01:51 +00:00
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"' ,
2026-08-30 11:31:24 +00:00
'source_list=""' ,
'for a in "$@"; do' ,
' case "$a" in Dir::Etc::sourcelist=*) source_list="${a#*=}";; esac' ,
'done' ,
2026-07-22 22:01:51 +00:00
'for a in "$@"; do' ,
' if [ "$a" = update ]; then' ,
2026-08-30 11:31:24 +00:00
' if [ -n "$source_list" ] && grep -q "signed-by=" "$source_list" && [ -n "${SOURCE_UPDATE_ERROR:-}" ]; then' ,
' printf "%s\\n" "$SOURCE_UPDATE_ERROR" >&2' ,
' exit 100' ,
' fi' ,
2026-07-22 22:01:51 +00:00
' 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' ) ) ;
2026-07-22 23:17:20 +00:00
// Restrictive umask: apt-readable 0644 files must come from the script's
// explicit chmod, not from a lucky default.
const res = spawnSync ( 'bash' , [ '-c' , 'umask 077 && exec bash "$1"' , 'bash' , SCRIPT ] , {
2026-07-22 22:01:51 +00:00
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 || '' ,
2026-07-26 21:41:16 +00:00
RELEASE _STATUS : releaseStatus || '' ,
2026-08-30 11:31:24 +00:00
SOURCE _UPDATE _ERROR : sourceUpdateError || '' ,
2026-08-30 11:34:06 +00:00
FORGE _USER : forgeUser || '' ,
FORGE _TOKEN : forgeToken || '' ,
2026-08-31 10:48:42 +00:00
STOKE _ALLOW _UNVERIFIED _APT : allowUnverified || '' ,
2026-07-22 22:01:51 +00:00
LC _ALL : 'es_ES.UTF-8' , // localized environment; the script must force C
} ,
} ) ;
const read = ( p ) => ( fs . existsSync ( p ) ? fs . readFileSync ( p , 'utf8' ) : null ) ;
2026-07-22 23:17:20 +00:00
const mode = ( p ) => ( fs . existsSync ( p ) ? fs . statSync ( p ) . mode & 0o777 : null ) ;
2026-07-23 00:08:06 +00:00
const result = {
res ,
2026-07-22 22:01:51 +00:00
aptEtc ,
nodesourceList : read ( path . join ( aptEtc , 'sources.list.d' , 'nodesource.list' ) ) ,
2026-07-22 23:17:20 +00:00
nodesourceListMode : mode ( path . join ( aptEtc , 'sources.list.d' , 'nodesource.list' ) ) ,
2026-07-22 22:01:51 +00:00
nodesourceKey : read ( path . join ( aptEtc , 'keyrings' , 'nodesource.asc' ) ) ,
2026-07-22 23:17:20 +00:00
nodesourceKeyMode : mode ( path . join ( aptEtc , 'keyrings' , 'nodesource.asc' ) ) ,
forgeKeyMode : mode ( path . join ( aptEtc , 'keyrings' , 'forgejo-heavy-duty.asc' ) ) ,
2026-08-30 11:31:24 +00:00
forgeList : read ( path . join ( aptEtc , 'sources.list.d' , 'forgejo-heavy-duty.list' ) ) ,
2026-08-30 11:34:06 +00:00
forgeAuth : read ( path . join ( aptEtc , 'auth.conf.d' , 'forgejo-heavy-duty.conf' ) ) ,
forgeAuthMode : mode ( path . join ( aptEtc , 'auth.conf.d' , 'forgejo-heavy-duty.conf' ) ) ,
2026-07-22 22:01:51 +00:00
aptGetLog : read ( path . join ( state , 'apt-get.log' ) ) || '' ,
} ;
2026-07-22 23:18:43 +00:00
// Drop the throwaway tree after we have read everything we need.
fs . rmSync ( root , { recursive : true , force : true } ) ;
return result ;
2026-07-22 22:01:51 +00:00
}
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/ ) ;
2026-07-22 23:17:20 +00:00
assert . equal ( s . forgeKeyMode , 0o644 , 'forge keyring must be readable by _apt' ) ;
2026-07-22 22:01:51 +00:00
} ) ;
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' ) ;
2026-07-22 23:17:20 +00:00
assert . equal ( s . nodesourceKeyMode , 0o644 , 'NodeSource keyring must be readable by _apt' ) ;
assert . equal ( s . nodesourceListMode , 0o644 , 'NodeSource list must be readable by _apt' ) ;
2026-07-22 22:01:51 +00:00
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/ ) ;
} ) ;
2026-07-26 21:41:16 +00:00
test ( 'registry Release file 404s: fails fast with a clear message before apt runs' , ( ) => {
const s = runScenario ( { candInitial : '22.23.1-1nodesource1' , releaseStatus : '404' } ) ;
assert . notEqual ( s . res . status , 0 ) ;
assert . match ( s . res . stderr , /no stoke package has been published/ ) ;
assert . match ( s . res . stderr , /npm/ ) ;
assert . match ( s . res . stderr , /dists\/stable\/Release returned 404/ ) ;
assert . equal ( s . aptGetLog , '' , 'must abort before any apt-get invocation' ) ;
} ) ;
test ( 'registry Release file present: proceeds with the install' , ( ) => {
const s = runScenario ( { candInitial : '22.23.1-1nodesource1' , releaseStatus : '200' } ) ;
assert . equal ( s . res . status , 0 , s . res . stderr ) ;
assert . match ( s . aptGetLog , /install -y stoke/ ) ;
} ) ;
2026-08-30 11:31:24 +00:00
2026-08-31 10:48:42 +00:00
test ( 'signature verification failure refuses by default and removes the forge source' , ( ) => {
2026-08-30 11:31:24 +00:00
const s = runScenario ( {
candInitial : '22.23.1-1nodesource1' ,
2026-08-31 10:57:32 +00:00
sourceUpdateError : 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set' ,
2026-08-30 11:31:24 +00:00
} ) ;
2026-08-31 10:48:42 +00:00
assert . notEqual ( s . res . status , 0 ) ;
assert . equal ( s . forgeList , null ) ;
assert . match ( s . res . stderr , /sqv-based apt cannot parse\s+the Forgejo registry signature/ ) ;
assert . match ( s . res . stderr , /STOKE_ALLOW_UNVERIFIED_APT=1/ ) ;
assert . doesNotMatch ( s . aptGetLog , /install -y stoke/ ) ;
} ) ;
test ( 'exact opt-in permits an HTTPS-only forge source after signature failure' , ( ) => {
const s = runScenario ( {
candInitial : '22.23.1-1nodesource1' ,
2026-08-31 10:57:32 +00:00
sourceUpdateError : 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set' ,
2026-08-31 10:48:42 +00:00
allowUnverified : '1' ,
} ) ;
2026-08-30 11:31:24 +00:00
assert . equal ( s . res . status , 0 , s . res . stderr ) ;
assert . match ( s . forgeList , /\[trusted=yes\]/ ) ;
2026-08-31 10:48:42 +00:00
assert . match ( s . res . stderr , /OpenPGP signature verification is disabled/ ) ;
assert . match ( s . res . stderr , /HTTPS-only integrity/ ) ;
2026-08-30 11:31:24 +00:00
assert . match ( s . aptGetLog , /install -y stoke/ ) ;
} ) ;
2026-08-31 10:57:32 +00:00
test ( 'opt-in cannot bypass a missing signing key' , ( ) => {
const failure = 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.' ;
const s = runScenario ( {
candInitial : '22.23.1-1nodesource1' ,
sourceUpdateError : failure ,
allowUnverified : '1' ,
} ) ;
assert . notEqual ( s . res . status , 0 ) ;
assert . match ( s . res . stderr , new RegExp ( failure . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ) ) ;
assert . match ( s . forgeList , /\[signed-by=/ ) ;
assert . doesNotMatch ( s . forgeList , /trusted=yes/ ) ;
assert . doesNotMatch ( s . aptGetLog , /install -y stoke/ ) ;
} ) ;
2026-08-31 10:48:42 +00:00
test ( 'unrecognized opt-in value is rejected before configuring apt' , ( ) => {
const s = runScenario ( {
candInitial : '22.23.1-1nodesource1' ,
allowUnverified : 'yes' ,
} ) ;
assert . notEqual ( s . res . status , 0 ) ;
assert . match ( s . res . stderr , /STOKE_ALLOW_UNVERIFIED_APT must be unset or exactly 1/ ) ;
assert . equal ( s . forgeList , null ) ;
assert . equal ( s . aptGetLog , '' ) ;
} ) ;
2026-08-30 11:31:24 +00:00
test ( 'network update failure stays fatal and never disables signature verification' , ( ) => {
const failure = 'Temporary failure resolving forgejo.heavyduty.builders' ;
const s = runScenario ( {
candInitial : '22.23.1-1nodesource1' ,
sourceUpdateError : failure ,
} ) ;
assert . notEqual ( s . res . status , 0 ) ;
assert . match ( s . res . stderr , new RegExp ( failure ) ) ;
assert . match ( s . forgeList , /\[signed-by=/ ) ;
assert . doesNotMatch ( s . forgeList , /trusted=yes/ ) ;
assert . doesNotMatch ( s . aptGetLog , /install -y stoke/ ) ;
} ) ;
2026-08-30 11:34:06 +00:00
test ( 'private-registry credentials stay in a root-readable auth file, not the source URL' , ( ) => {
const s = runScenario ( {
candInitial : '22.23.1-1nodesource1' ,
forgeUser : 'apt-user' ,
forgeToken : 'secret-token' ,
} ) ;
assert . equal ( s . res . status , 0 , s . res . stderr ) ;
assert . equal ( s . forgeAuthMode , 0o600 ) ;
assert . equal ( s . forgeAuth , [
'machine forgejo.heavyduty.builders' ,
'login apt-user' ,
'password secret-token' ,
'' ,
] . join ( '\n' ) ) ;
assert . doesNotMatch ( s . forgeList , /apt-user|secret-token/ ) ;
} ) ;
test ( 'incomplete private-registry credentials fail before configuring apt' , ( ) => {
const s = runScenario ( {
candInitial : '22.23.1-1nodesource1' ,
forgeUser : 'apt-user' ,
} ) ;
assert . notEqual ( s . res . status , 0 ) ;
assert . match ( s . res . stderr , /FORGE_USER and FORGE_TOKEN must be set together/ ) ;
assert . equal ( s . forgeList , null ) ;
assert . equal ( s . aptGetLog , '' ) ;
} ) ;