Vestibule/scripts/test-provision-linux.sh

444 lines
22 KiB
Bash
Executable File

#!/bin/sh
# test-provision-linux.sh — rootless integration test for the Linux
# kiosk provisioning pipeline (LibreWolf AND Firefox).
#
# Runs provision-kiosk.sh and deprovision-kiosk.sh against a sandbox:
# all privileged destinations (/opt, /etc, /usr/local/bin, systemd) are
# rewritten to a temp directory, and privileged commands (useradd,
# systemctl, chown, id) are replaced with shims. No root required, no
# real system mutation — this is what CI runs on every push.
#
# Scenarios:
# 1. --librewolf native full provision + deprovision cycle
# 2. --firefox native /etc/firefox/policies path (distro Firefox)
# 3. --firefox flatpak kiosk-home policy path + sandbox XPI copy
#
# What it verifies per scenario:
# provision: kiosk user creation, /opt staging, XPI build (valid zip
# with manifest.json), launcher install, kiosk.env browser
# + flavor, Native Messaging manifests (all five Gecko
# locations, valid JSON, correct path), policies.json
# deep-merge (the browser's own keys survive, ours added,
# extension force-installed with the right install_url),
# unit generation + enable.
# deprovision: unit removed, policies.json equal to the pre-provision
# baseline byte-for-byte, manifests + sandbox XPI copies
# removed, staged files removed.
#
# After the scenarios, the launcher dispatch is exercised directly: all
# four env permutations (firefox/flatpak, firefox/native,
# librewolf/flatpak, defaults) run against browser shims and the exec'd
# command line is asserted.
#
# Usage: sh scripts/test-provision-linux.sh (KEEP_SANDBOX=1 to inspect)
# Exit: 0 pass, 1 fail
#
# POSIX sh — no bashisms.
set -u
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
SANDBOX=$(mktemp -d)
PASS=0
FAIL=0
KIOSK_USER="vestibule-kiosk"
cleanup() {
if [ "${KEEP_SANDBOX:-0}" = "1" ]; then
say "sandbox kept at: ${SANDBOX}"
else
rm -rf "${SANDBOX}"
fi
}
trap cleanup EXIT INT TERM
say() { printf '%s\n' "$1"; }
pass() { PASS=$((PASS+1)); say " [pass] $1"; }
fail() { FAIL=$((FAIL+1)); say " [FAIL] $1"; }
check() { # check <description> <command...>
desc="$1"; shift
if "$@" >/dev/null 2>&1; then pass "${desc}"; else fail "${desc}"; fi
}
# ─── Sandbox layout ───────────────────────────────────────────────────
#
# repo/ copy of the real repo (scripts/, config/, extension/)
# root/opt fake /opt/vestibule
# root/etc fake /etc/vestibule + /etc/systemd/system
# root/etc/firefox fake /etc/firefox/policies (distro Firefox)
# root/usrlocal fake /usr/local/bin
# root/usr/lib/firefox fake distro Firefox install
# librewolf/ fake native LibreWolf install (wrapper in PATH)
# home/ fake kiosk user home
# bin/ PATH shims (privileged + browser + usher)
mkdir -p "${SANDBOX}/repo" "${SANDBOX}/root/opt" "${SANDBOX}/root/etc/systemd/system" \
"${SANDBOX}/root/etc/firefox/policies" "${SANDBOX}/root/usrlocal" \
"${SANDBOX}/root/usr/lib/firefox" "${SANDBOX}/bin" \
"${SANDBOX}/librewolf/browser" "${SANDBOX}/librewolf/distribution" \
"${SANDBOX}/home"
cp -r "${REPO_ROOT}/scripts" "${REPO_ROOT}/config" "${REPO_ROOT}/extension" "${SANDBOX}/repo/"
mkdir -p "${SANDBOX}/repo/helper/target/release"
printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/repo/helper/target/release/usher"
chmod +x "${SANDBOX}/repo/helper/target/release/usher"
# Fake native LibreWolf: wrapper in PATH + real install dir with markers.
printf '#!/bin/sh\nexec "%s/librewolf/librewolf" "$@"\n' "${SANDBOX}" > "${SANDBOX}/bin/librewolf"
chmod +x "${SANDBOX}/bin/librewolf"
# Fake LibreWolf binary: logs its argv (launcher dispatch assertions).
printf '#!/bin/sh\nprintf "%%s\\n" "librewolf $*" >> "%s/browser.log"\n' "${SANDBOX}" \
> "${SANDBOX}/librewolf/librewolf"
chmod +x "${SANDBOX}/librewolf/librewolf"
touch "${SANDBOX}/librewolf/application.ini"
# Fake Firefox in PATH for the launcher dispatch assertions. Provision
# detection never consults it: the canonical /usr/lib/firefox path wins.
printf '#!/bin/sh\nprintf "%%s\\n" "firefox $*" >> "%s/browser.log"\n' "${SANDBOX}" \
> "${SANDBOX}/bin/firefox"
chmod +x "${SANDBOX}/bin/firefox"
# Pre-seed LibreWolf's own shipped policies — the merge must preserve
# them and the deprovision must return this baseline byte-for-byte.
cat > "${SANDBOX}/librewolf/distribution/policies.json" <<'EOF'
{
"policies": {
"DisableAppUpdate": true,
"LibreWolfOwnSetting": "must-survive"
}
}
EOF
cp "${SANDBOX}/librewolf/distribution/policies.json" "${SANDBOX}/original-lw-policies.json"
# Fake distro Firefox at /usr/lib/firefox (canonical Debian/Arch layout;
# Fedora uses /usr/lib64 — same code path). No PATH wrapper: detection
# must find it via the canonical path.
printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/root/usr/lib/firefox/firefox"
chmod +x "${SANDBOX}/root/usr/lib/firefox/firefox"
touch "${SANDBOX}/root/usr/lib/firefox/application.ini"
# Pre-seed Firefox's own policies in /etc/firefox/policies.
cat > "${SANDBOX}/root/etc/firefox/policies/policies.json" <<'EOF'
{
"policies": {
"DisableTelemetry": true,
"FirefoxOwnSetting": "must-survive"
}
}
EOF
cp "${SANDBOX}/root/etc/firefox/policies/policies.json" "${SANDBOX}/original-ff-policies.json"
# ─── PATH shims ───────────────────────────────────────────────────────
printf '#!/bin/sh\n# id shim: "id -u" -> 0 (root); "id -u NAME" -> uid or fail if absent\nif [ "$1" = "-u" ] && [ $# -eq 1 ]; then echo 0; exit 0; fi\nname="$2"\nif grep -q "^${name}:" "%s/passwd" 2>/dev/null; then echo 1500; exit 0; fi\nexit 1\n' \
"${SANDBOX}" > "${SANDBOX}/bin/id"
printf '#!/bin/sh\necho useradd "$@" >> "%s/priv.log"\nmkdir -p "%s/home/vestibule-kiosk"\nprintf "vestibule-kiosk:x:1500:1500::%s/home/vestibule-kiosk:/bin/sh\\n" >> "%s/passwd"\n' \
"${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" > "${SANDBOX}/bin/useradd"
printf '#!/bin/sh\nif [ "$1" = "passwd" ] && [ "$2" = "vestibule-kiosk" ]; then grep "^vestibule-kiosk:" "%s/passwd"; fi\nexit 0\n' \
"${SANDBOX}" > "${SANDBOX}/bin/getent"
printf '#!/bin/sh\necho systemctl "$@" >> "%s/priv.log"\nexit 0\n' "${SANDBOX}" > "${SANDBOX}/bin/systemctl"
printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/bin/chown"
printf '#!/bin/sh\necho userdel "$@" >> "%s/priv.log"\ngrep -v "^vestibule-kiosk:" "%s/passwd" > "%s/passwd.tmp" && mv "%s/passwd.tmp" "%s/passwd"\nexit 0\n' \
"${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" > "${SANDBOX}/bin/userdel"
# cage shim: log the invocation, then exec the client after "--". The
# harness pins VESTIBULE_CAGE_ARGS to a single flag (-d) in every
# launcher test, so exactly two arguments precede the client.
printf '#!/bin/sh\nprintf "%%s\\n" "cage $*" >> "%s/browser.log"\nshift 2\nexec "$@"\n' "${SANDBOX}" > "${SANDBOX}/bin/cage"
# dbus-run-session shim: pass straight through to the compositor.
printf '#!/bin/sh\nshift\nexec "$@"\n' > "${SANDBOX}/bin/dbus-run-session"
# flatpak shim: system installs "exist" (info --system succeeds), user
# installs do not, and "run" is a logged browser launch.
printf '#!/bin/sh\nif [ "$1" = "info" ]; then [ "$2" = "--system" ] && exit 0; exit 1; fi\nif [ "$1" = "run" ]; then printf "%%s\\n" "flatpak $*" >> "%s/browser.log"; exit 0; fi\nexit 1\n' \
"${SANDBOX}" > "${SANDBOX}/bin/flatpak"
for f in "${SANDBOX}/bin/"*; do chmod +x "$f"; done
# ─── Rewrite privileged paths in the scripts under test ───────────────
rewrite() {
sed -e "s|/run/systemd/system|${SANDBOX}/run-systemd|g" \
-e "s|/opt/vestibule|${SANDBOX}/root/opt/vestibule|g" \
-e "s|/etc/vestibule|${SANDBOX}/root/etc/vestibule|g" \
-e "s|/etc/systemd/system|${SANDBOX}/root/etc/systemd/system|g" \
-e "s|/etc/firefox|${SANDBOX}/root/etc/firefox|g" \
-e "s|/usr/lib/firefox|${SANDBOX}/root/usr/lib/firefox|g" \
-e "s|/usr/local/bin|${SANDBOX}/root/usrlocal|g" \
-e "s|/usr/lib/librewolf|${SANDBOX}/librewolf|g" \
"$1" > "$2"
}
mkdir -p "${SANDBOX}/run-systemd"
rewrite "${REPO_ROOT}/scripts/provision-kiosk.sh" "${SANDBOX}/repo/scripts/provision-kiosk.sh"
rewrite "${REPO_ROOT}/scripts/deprovision-kiosk.sh" "${SANDBOX}/repo/scripts/deprovision-kiosk.sh"
rewrite "${REPO_ROOT}/scripts/vestibule-kiosk-launch" "${SANDBOX}/repo/scripts/vestibule-kiosk-launch"
export PATH="${SANDBOX}/bin:${PATH}"
NM="${SANDBOX}/home/vestibule-kiosk"
UNIT="${SANDBOX}/root/etc/systemd/system/vestibule-kiosk.service"
run_provision() {
sh "${SANDBOX}/repo/scripts/provision-kiosk.sh" "$@" \
--kiosk-user "${KIOSK_USER}" --tty 2 --yes \
> "${SANDBOX}/provision.out" 2>&1
}
run_deprovision() {
sh "${SANDBOX}/repo/scripts/deprovision-kiosk.sh" \
--kiosk-user "${KIOSK_USER}" \
--remove-user --remove-opt --remove-usher --yes \
> "${SANDBOX}/deprovision.out" 2>&1
}
report_on_fail() {
if [ "$1" -ne 0 ]; then
sed -n '1,60p' "${SANDBOX}/provision.out" 2>/dev/null
sed -n '1,60p' "${SANDBOX}/deprovision.out" 2>/dev/null
fi
}
assert_common_provision() {
# Everything browser-independent: staging, launcher, unit, NM.
check "usher installed" test -x "${SANDBOX}/root/usrlocal/usher"
check "usher staged under /opt" test -x "${SANDBOX}/root/opt/vestibule/bin/usher"
check "launcher installed" test -x "${SANDBOX}/root/usrlocal/vestibule-kiosk-launch"
check "XPI built" test -f "${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi"
if python3 -c "
import zipfile, json
z = zipfile.ZipFile('${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi')
m = json.loads(z.read('manifest.json'))
assert m['version'] == '1.2.2', m['version']
"; then pass "XPI valid zip with manifest.json v1.2.2"; else fail "XPI valid zip with manifest.json v1.2.2"; fi
check "unit generated" test -f "${UNIT}"
check "unit User substituted" grep -q '^User=vestibule-kiosk$' "${UNIT}"
check "unit TTYPath substituted" grep -q '^TTYPath=/dev/tty2$' "${UNIT}"
check "unit conflicts getty" grep -q 'Conflicts=getty@tty2.service' "${UNIT}"
check "unit execs launcher" grep -q 'ExecStart=/usr/local/bin/vestibule-kiosk-launch' "${UNIT}"
check "systemctl daemon-reload" grep -q "daemon-reload" "${SANDBOX}/priv.log"
check "systemctl enable" grep -q "enable vestibule-kiosk.service" "${SANDBOX}/priv.log"
# Native Messaging manifests: all five Gecko locations, valid JSON.
for loc in ".librewolf" ".mozilla" \
".var/app/io.gitlab.librewolf-community/.librewolf" \
".var/app/org.mozilla.firefox/.mozilla" \
"snap/firefox/common/.mozilla"; do
f="${NM}/${loc}/native-messaging-hosts/com.vestibule.usher.json"
check "NM manifest ${loc}" test -f "${f}"
if [ -f "${f}" ] && python3 -c "
import json
m = json.load(open('${f}'))
assert m['path'] == '${SANDBOX}/root/usrlocal/usher', m['path']
assert m['allowed_extensions'] == ['vestibule@vestibule.kiosk']
assert m['type'] == 'stdio'
"; then pass "NM manifest ${loc} valid"; else fail "NM manifest ${loc} valid"; fi
done
}
assert_common_deprovision() {
check "unit removed" test ! -f "${UNIT}"
check "launcher removed" test ! -e "${SANDBOX}/root/usrlocal/vestibule-kiosk-launch"
check "kiosk.env removed" test ! -e "${SANDBOX}/root/etc/vestibule"
check "/opt/vestibule removed" test ! -e "${SANDBOX}/root/opt/vestibule"
check "usher removed" test ! -e "${SANDBOX}/root/usrlocal/usher"
check "userdel called" grep -q "userdel" "${SANDBOX}/priv.log"
check "NM manifests removed" test ! -e "${NM}/.librewolf/native-messaging-hosts/com.vestibule.usher.json"
check "NM manifests removed (firefox flatpak)" test ! -e "${NM}/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/com.vestibule.usher.json"
check "NM manifests removed (firefox snap)" test ! -e "${NM}/snap/firefox/common/.mozilla/native-messaging-hosts/com.vestibule.usher.json"
check "sandbox XPI copy removed" test ! -e "${NM}/.var/app/org.mozilla.firefox/vestibule.xpi"
}
# ══════════════════════════════════════════════════════════════════════
# Scenario 1: LibreWolf, native
# ══════════════════════════════════════════════════════════════════════
say ""
say "==> scenario 1: provision --librewolf native"
if run_provision --librewolf native --home-url https://checkin.example.org; then
pass "provision-kiosk.sh exited 0"
else
fail "provision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_provision
check "kiosk.env written" test -f "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env home URL" grep -q 'VESTIBULE_HOME_URL="https://checkin.example.org"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env browser librewolf" grep -q 'VESTIBULE_BROWSER="librewolf"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env flavor native" grep -q 'VESTIBULE_BROWSER_FLAVOR="native"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
POL="${SANDBOX}/librewolf/distribution/policies.json"
if python3 -c "
import json
p = json.load(open('${POL}'))['policies']
assert p['LibreWolfOwnSetting'] == 'must-survive', 'pre-existing key lost'
assert p['DisablePrivateBrowsing'] is True, 'canonical key missing'
assert p['SanitizeOnShutdown']['Cookies'] is True, 'nested key missing'
es = p['ExtensionSettings']
assert es['*']['installation_mode'] == 'blocked'
assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed'
assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url']
"; then pass "policies.json deep-merge correct (librewolf)"; else fail "policies.json deep-merge correct (librewolf)"; fi
check "policies backup created" test -f "${POL}.vestibule-bak"
say ""
say "==> scenario 1: deprovision"
if run_deprovision; then
pass "deprovision-kiosk.sh exited 0"
else
fail "deprovision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_deprovision
if cmp -s "${POL}" "${SANDBOX}/original-lw-policies.json"; then
pass "librewolf policies.json equals the pre-provision baseline (byte-for-byte)"
else
fail "librewolf policies.json equals the pre-provision baseline (byte-for-byte)"
fi
# ══════════════════════════════════════════════════════════════════════
# Scenario 2: Firefox, native (distro package -> /etc/firefox/policies)
# ══════════════════════════════════════════════════════════════════════
say ""
say "==> scenario 2: provision --firefox native"
if run_provision --firefox native --home-url https://portal.example.org; then
pass "provision-kiosk.sh exited 0"
else
fail "provision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_provision
FFPOL="${SANDBOX}/root/etc/firefox/policies/policies.json"
check "kiosk.env browser firefox" grep -q 'VESTIBULE_BROWSER="firefox"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env flavor native (ff)" grep -q 'VESTIBULE_BROWSER_FLAVOR="native"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "firefox policies file deployed" test -f "${FFPOL}"
if python3 -c "
import json
p = json.load(open('${FFPOL}'))['policies']
assert p['FirefoxOwnSetting'] == 'must-survive', 'pre-existing Firefox key lost'
assert p['DisablePrivateBrowsing'] is True, 'canonical key missing'
assert p['SanitizeOnShutdown']['Cookies'] is True, 'nested key missing'
es = p['ExtensionSettings']
assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed'
assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url']
"; then pass "firefox /etc/firefox policies deep-merge correct"; else fail "firefox /etc/firefox policies deep-merge correct"; fi
check "firefox policies backup created" test -f "${FFPOL}.vestibule-bak"
# Firefox provisioning must not touch the LibreWolf install.
if cmp -s "${POL}" "${SANDBOX}/original-lw-policies.json"; then
pass "librewolf policies untouched by firefox provision"
else
fail "librewolf policies untouched by firefox provision"
fi
say ""
say "==> scenario 2: deprovision"
if run_deprovision; then
pass "deprovision-kiosk.sh exited 0"
else
fail "deprovision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_deprovision
if cmp -s "${FFPOL}" "${SANDBOX}/original-ff-policies.json"; then
pass "firefox /etc/firefox policies.json equals the pre-provision baseline (byte-for-byte)"
else
fail "firefox /etc/firefox policies.json equals the pre-provision baseline (byte-for-byte)"
fi
# ══════════════════════════════════════════════════════════════════════
# Scenario 3: Firefox, Flatpak (kiosk-home policy path + XPI copy)
# ══════════════════════════════════════════════════════════════════════
say ""
say "==> scenario 3: provision --firefox flatpak"
if run_provision --firefox flatpak --home-url https://lobby.example.org; then
pass "provision-kiosk.sh exited 0"
else
fail "provision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_provision
FFHOME_POL="${NM}/.var/app/org.mozilla.firefox/.mozilla/policies/policies.json"
check "kiosk.env browser firefox (fp)" grep -q 'VESTIBULE_BROWSER="firefox"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env flavor flatpak (fp)" grep -q 'VESTIBULE_BROWSER_FLAVOR="flatpak"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "XPI copied to flatpak home" test -f "${NM}/.var/app/org.mozilla.firefox/vestibule.xpi"
check "flatpak policies file deployed" test -f "${FFHOME_POL}"
if python3 -c "
import json
p = json.load(open('${FFHOME_POL}'))['policies']
es = p['ExtensionSettings']
assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed'
# install_url must point INSIDE the sandbox-visible home, not /opt.
assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${NM}/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url']
"; then pass "flatpak install_url points at sandbox-visible XPI"; else fail "flatpak install_url points at sandbox-visible XPI"; fi
say ""
say "==> scenario 3: deprovision"
if run_deprovision; then
pass "deprovision-kiosk.sh exited 0"
else
fail "deprovision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_deprovision
check "flatpak-home policies removed" test ! -e "${FFHOME_POL}"
# ════════════════════════════════════════════════════════════════════
# Launcher dispatch: all four env permutations run against browser
# shims; the exec'd command line is asserted, not just parsed.
# ════════════════════════════════════════════════════════════════════
say ""
say "==> launcher dispatch"
LAUNCHER="${SANDBOX}/repo/scripts/vestibule-kiosk-launch"
KIOSK_ENV="${SANDBOX}/root/etc/vestibule/kiosk.env"
URL="https://launch.example.org"
write_kiosk_env() {
mkdir -p "${SANDBOX}/root/etc/vestibule"
{
printf 'VESTIBULE_HOME_URL="%s"\n' "${URL}"
printf 'VESTIBULE_CAGE_ARGS="-d"\n'
[ -n "${1:-}" ] && printf 'VESTIBULE_BROWSER="%s"\n' "$1"
[ -n "${2:-}" ] && printf 'VESTIBULE_BROWSER_FLAVOR="%s"\n' "$2"
} > "${KIOSK_ENV}"
}
launcher_check() {
# $1 = description, $2 = expected command line (fixed string)
desc="$1"; expect="$2"
rm -f "${SANDBOX}/browser.log"
sh "${LAUNCHER}" > "${SANDBOX}/launcher.out" 2>&1
if grep -qF "${expect}" "${SANDBOX}/browser.log" 2>/dev/null; then
pass "launcher dispatches ${desc}"
else
fail "launcher dispatches ${desc} (log: $(cat "${SANDBOX}/browser.log" 2>/dev/null || echo empty))"
fi
}
write_kiosk_env firefox flatpak
launcher_check "firefox/flatpak -> flatpak run" \
"flatpak run org.mozilla.firefox --kiosk -P vestibule-profile -no-remote ${URL}"
write_kiosk_env firefox native
launcher_check "firefox/native -> firefox" \
"firefox --kiosk -P vestibule-profile -no-remote ${URL}"
write_kiosk_env librewolf flatpak
launcher_check "librewolf/flatpak -> flatpak run" \
"flatpak run io.gitlab.librewolf-community --kiosk -P vestibule-profile -no-remote ${URL}"
write_kiosk_env "" ""
launcher_check "defaults -> librewolf native" \
"librewolf --kiosk -P vestibule-profile -no-remote ${URL}"
# ─── Summary ──────────────────────────────────────────────────────────
say ""
if [ "${FAIL}" -eq 0 ]; then
say "OK — ${PASS} assertions passed: kiosk provision/deprovision works for LibreWolf native, Firefox native, and Firefox Flatpak."
exit 0
else
say "FAIL: ${FAIL} failed of $((PASS+FAIL))"
exit 1
fi