From fdf0eb9d2a61b6ae387c6b9678ea419c5aed672a Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:31:43 -0700 Subject: [PATCH 01/10] Split bin_scripts.bats by script and cut its waits --- tests/bin_firetv.bats | 738 ++++++++++ tests/bin_ph_backup.bats | 550 ++++++++ tests/bin_pihole.bats | 1482 ++++++++++++++++++++ tests/bin_scripts.bats | 2754 +------------------------------------- 4 files changed, 2797 insertions(+), 2727 deletions(-) create mode 100644 tests/bin_firetv.bats create mode 100644 tests/bin_ph_backup.bats create mode 100644 tests/bin_pihole.bats diff --git a/tests/bin_firetv.bats b/tests/bin_firetv.bats new file mode 100644 index 0000000..7095ec3 --- /dev/null +++ b/tests/bin_firetv.bats @@ -0,0 +1,738 @@ +#!/usr/bin/env bats +# Tests for firetv against a mocked adb and ph. + +load test_helper + +setup() { + # Copies of the tools made by the tests find the shared helpers here. + export DOTFILES_DIR="${PROJECT_ROOT}" + setup_tmpdir + export BIN_SANDBOX="${TEST_TMPDIR}/bin" + mkdir -p "${BIN_SANDBOX}" +} + +teardown() { + teardown_tmpdir +} + +setup_firetv_sandbox() { + FIRETV="${PROJECT_ROOT}/stow/personal/.local/bin/firetv" + export FIRETV_STATE="${TEST_TMPDIR}/tv" + export FIRETV_STATE_DIR="${TEST_TMPDIR}/state" + # Never the real Pi-hole: this ph refuses until setup_firetv_pihole replaces it. + export PH_BIN="${BIN_SANDBOX}/ph" + export FIRETV_TWEAKS_FILE="${TEST_TMPDIR}/no-tweaks" + printf '%s\n' '#!/usr/bin/env bash' \ + "printf 'ph: no password: run '\\''ph login'\\'' or set PIHOLE_PASSWORD\\n' >&2" 'exit 1' > "${PH_BIN}" + chmod +x "${PH_BIN}" + mkdir -p "${FIRETV_STATE}" + # The TV: installed packages and disabled ones, one per line. + printf '%s\n' com.amazon.tv.acr com.amazon.device.metrics com.amazon.device.metrics \ + com.amazon.storm.lightning.tutorial com.amazon.tv.launcher > "${FIRETV_STATE}/installed" + printf '%s\n' com.amazon.storm.lightning.tutorial > "${FIRETV_STATE}/disabled" + cat > "${BIN_SANDBOX}/adb" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${FIRETV_STATE}/calls.log" +[[ "${1:-}" == -s ]] && shift 2 +# Like real adb shell, drain stdin. +[[ "${1:-}" != shell ]] || cat > /dev/null +if [[ "$*" == "shell pm list packages"* && -e "${FIRETV_STATE}/pm-broken" ]]; then + printf 'cmd: Failure calling service package\n' + exit 20 +fi +case "$*" in + "connect "*) printf 'connected to %s\n' "${2}" ;; + get-state) + if [[ -e "${FIRETV_STATE}/offline" ]]; then printf 'offline\n'; else printf '%s\n' "${ADB_STATE:-device}"; fi ;; + "disconnect "*) rm -f "${FIRETV_STATE}/offline" ;; + "shell getprop ro.product.model") printf 'AFTDEC012E\r\n' ;; + "shell getprop ro.build.version.name") printf 'Fire OS 8.1.8.5 (RS8185/3885)\r\n' ;; + "shell pm list packages -3 --show-versioncode") + printf 'package:%s\r\n' 'com.netflix.ninja versionCode:27207' 'com.amazon.tv.settings.v2.overlay.localisation.common versionCode:0' \ + $({ grep -v -E '^(com\.amazon|com\.example)' "${FIRETV_STATE}/installed" || true; } | sed 's/$/ versionCode:1/' | tr ' ' '#') | tr '#' ' ' ;; + "shell pm list packages -d") sed 's/^/package:/' "${FIRETV_STATE}/disabled" ;; + "shell pm list packages"*) sed 's/^/package:/' "${FIRETV_STATE}/installed" ;; + # Settings as "NAMESPACE KEY VALUE" lines, the last one winning; "-" deletes. + "shell settings put "*) printf '%s %s %s\n' "$4" "$5" "$6" >> "${FIRETV_STATE}/settings" ;; + "shell settings delete "*) printf '%s %s -\n' "$4" "$5" >> "${FIRETV_STATE}/settings" ;; + "shell settings get "*) + value="$(awk -v ns="$4" -v k="$5" '$1 == ns && $2 == k { v = $3 } END { print v }' "${FIRETV_STATE}/settings" 2>/dev/null || true)" + [[ "${value}" != - ]] || value="" + printf '%s\r\n' "${value:-null}" ;; + "shell settings list "*) + awk -v ns="$4" '$1 == ns { if (!($2 in v)) o[++n] = $2; v[$2] = $3 } END { for (i = 1; i <= n; i++) if (v[o[i]] != "-") printf "%s=%s\r\n", o[i], v[o[i]] }' \ + "${FIRETV_STATE}/settings" 2>/dev/null || true ;; + "install -r "*) + [[ "$3" != *broken* ]] || { printf 'Failure [INSTALL_PARSE_FAILED_NOT_APK]\n'; exit 1; } + basename "$3" .apk >> "${FIRETV_STATE}/installed" + printf 'Performing Streamed Install\r\nSuccess\r\n' ;; + "uninstall "*) + grep -vxF "$2" "${FIRETV_STATE}/installed" > "${FIRETV_STATE}/i.tmp" || true + mv "${FIRETV_STATE}/i.tmp" "${FIRETV_STATE}/installed" + printf 'Success\r\n' ;; + "shell df -k /data") + printf 'Filesystem 1K-blocks Used Available Use%% Mounted on\r\n/dev/block/dm-17 9339420 2299140 %s 26%% /data\r\n' \ + "$(cat "${FIRETV_STATE}/free" 2>/dev/null || printf 6892824)" ;; + "shell pm trim-caches 999G") printf '%s\n' "$(( $(cat "${FIRETV_STATE}/free" 2>/dev/null || printf 6892824) + 204800 ))" > "${FIRETV_STATE}/free" ;; + "shell dumpsys package com.amazon.venezia") printf ' Package [com.amazon.venezia]\r\n userId=10171\r\n' ;; + "shell dumpsys package com.amazon.device.metrics") + printf ' userId=10194\r\n versionCode=%s minSdk=30 targetSdk=28\r\n' "$(cat "${FIRETV_STATE}/metrics-version" 2>/dev/null || printf 1)" ;; + "shell dumpsys package "*) printf 'Unable to find package\r\n' ;; + "shell pm path com.amazon.venezia") + printf 'package:/system_ext/priv-app/com.amazon.venezia/com.amazon.venezia.apk\r\n' ;; + "shell pm path com.amazon.ftvads.deeplinking") + printf 'package:/system_ext/app/FtvAds/FtvAds.apk\r\n' ;; + "shell pm path com.amazon.tv.acr") printf 'package:/system_ext/app/Acr/Acr.apk\r\n' ;; + "shell pm path "*) ;; + *"for a in /system_ext/priv-app/com.amazon.venezia/com.amazon.venezia.apk;"*"strings -n 6"*) + printf '%s\r\n' appstore-tv-prod-na.amazon.com unagi-na.amazon.com 0com.amazon.dev \ + https://juggler.amazon.com ec2.eu-west-1.amazonaws.com ec2.us-east-1.amazonaws.com ;; + *"for a in /system_ext/app/FtvAds/FtvAds.apk;"*"strings -n 6"*) + printf '%s\r\n' mads.amazon.com aax-us-east.amazon-adsystem.com api.amazon.com never-looked-up.amazon.com ;; + *"strings -n 6"*) exit 1 ;; + *"/proc/net/tcp"*"-v u=10194 "*) + [[ ! -e "${FIRETV_STATE}/metrics-connects" ]] || printf '%s\r\n' 0000000000000000FFFF0000D3B1A736:01BB ;; + *"/proc/net/tcp"*) + [[ "$*" == *"-v u=10171 "* ]] || exit 1 + # 54.167.177.211:443 mapped into IPv6, then loopback, which drops. + printf '%s\r\n' 0000000000000000FFFF0000D3B1A736:01BB 0100007F:1F90 ;; + "shell pm disable-user --user 0 com.amazon.device.metrics") + printf 'java.lang.SecurityException: Shell cannot change component state\n' ;; + "shell pm disable-user --user 0 "*) + printf '%s\n' "${6}" >> "${FIRETV_STATE}/disabled" + printf 'Package %s new state: disabled-user\r\n' "${6}" ;; + "shell pm enable "*) + grep -vxF "${4}" "${FIRETV_STATE}/disabled" > "${FIRETV_STATE}/d.tmp" || true + mv "${FIRETV_STATE}/d.tmp" "${FIRETV_STATE}/disabled" + printf 'Package %s new state: enabled\n' "${4}" ;; + *) printf 'adb %s\n' "$*" ;; +esac +MOCK + chmod +x "${BIN_SANDBOX}/adb" +} + +@test "firetv shows help and rejects bad commands and groups" { + run "${PROJECT_ROOT}/stow/personal/.local/bin/firetv" --help + assert_success + assert_output --partial "disable TARGET..." + run "${PROJECT_ROOT}/stow/personal/.local/bin/firetv" + assert_failure 2 + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" bogus + assert_failure + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable + assert_failure + assert_output --partial "Name a group (telemetry, ads, bloat, all) or a package" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable nope + assert_failure + assert_output --partial "Unknown group or package: nope" + [[ ! -e "${FIRETV_STATE}/calls.log" ]] +} + +@test "firetv stops when the TV has not authorized this machine" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" ADB_STATE="error: device unauthorized" "${FIRETV}" list + assert_failure + assert_output --partial "Accept the prompt on the TV" +} + +@test "firetv list reports enabled, disabled, and absent packages" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --host 10.0.0.5 list bloat telemetry + assert_success + assert_line --regexp '^enabled +telemetry +com\.amazon\.tv\.acr ' + assert_line --regexp '^disabled +bloat +com\.amazon\.storm\.lightning\.tutorial ' + assert_line --regexp '^absent +telemetry +com\.amazon\.perfc ' + run grep -c -- '-s 10.0.0.5:5555' "${FIRETV_STATE}/calls.log" + refute_output "0" +} + +@test "firetv has no separate status or disabled command" { + setup_firetv_sandbox + for cmd in status disabled; do + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" "${cmd}" + assert_failure + assert_output --partial "Unknown command: ${cmd}" + done +} + +@test "firetv disable tries every package, skipping absent, done, and root-only ones" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --dry-run disable telemetry + assert_success + assert_output --partial "would disable com.amazon.tv.acr" + run grep -- 'disable-user' "${FIRETV_STATE}/calls.log" + assert_failure + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable all all telemetry + assert_success + assert_output --partial "com.amazon.device.metrics: needs root" + assert_output --partial "com.amazon.storm.lightning.tutorial: already disabled" + assert_output --partial "com.amazon.perfc: not on this TV" + assert_output --partial "1 disabled, 16 skipped, 1 need root, 0 failed" + run grep -c -- 'disable-user' "${FIRETV_STATE}/calls.log" + assert_output "2" + run grep -cxF com.amazon.tv.launcher "${FIRETV_STATE}/disabled" + assert_output "0" + + # A rerun changes nothing and still passes. + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable telemetry + assert_success + assert_output --partial "0 disabled" + run grep -c -- 'disable-user' "${FIRETV_STATE}/calls.log" + assert_output "3" +} + +@test "firetv enables and disables single packages, and refuses to disable core ones" { + setup_firetv_sandbox + printf '%s\n' com.amazon.media.recommendations com.example.extra >> "${FIRETV_STATE}/installed" + printf '%s\n' com.amazon.media.recommendations >> "${FIRETV_STATE}/disabled" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable com.amazon.media.recommendations + assert_success + assert_output --partial "com.amazon.media.recommendations: enabled ((not on the debloat list))" + assert_output --partial "1 enabled, 0 skipped" + run grep -cxF com.amazon.media.recommendations "${FIRETV_STATE}/disabled" + assert_output "0" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable com.amazon.media.recommendations + assert_failure + assert_output --partial "com.amazon.media.recommendations is a core package the TV needs" + run grep -c disable-user "${FIRETV_STATE}/calls.log" + assert_output "0" + + # A package name beside a group is tried once, even when the group has it. + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable com.example.extra telemetry com.amazon.tv.acr + assert_success + assert_output --partial "com.example.extra: disabled" + run grep -c 'disable-user --user 0 com.amazon.tv.acr$' "${FIRETV_STATE}/calls.log" + assert_output "1" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable 'com.x;reboot' + assert_failure + assert_output --partial "Unknown group or package" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" list + assert_success + refute_output --partial "media.recommendations" +} + +@test "firetv fails on an unexpected pm reply" { + setup_firetv_sandbox + printf '%s\n' com.amazon.gamehub >> "${FIRETV_STATE}/installed" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat + assert_success + sed -i.bak 's/new state: enabled/Unknown package/' "${BIN_SANDBOX}/adb" + printf '%s\n' com.amazon.gamehub >> "${FIRETV_STATE}/disabled" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat + assert_failure + assert_output --partial "com.amazon.gamehub: Package com.amazon.gamehub Unknown package" + assert_output --partial "1 failed" +} + +@test "firetv reconnects a connection left offline by a reboot" { + setup_firetv_sandbox + touch "${FIRETV_STATE}/offline" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" connect + assert_success + assert_output --partial "AFTDEC012E, Fire OS 8.1.8.5 (RS8185/3885)" + refute_output --partial "Fire OS Fire OS" + run grep -c '^disconnect ' "${FIRETV_STATE}/calls.log" + assert_output "1" +} + +@test "firetv says why when the package list cannot be read" { + setup_firetv_sandbox + touch "${FIRETV_STATE}/pm-broken" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable ads + assert_failure + assert_output --partial "Could not list packages on the TV" + run grep -- 'disable-user' "${FIRETV_STATE}/calls.log" + assert_failure +} + +@test "firetv rejects arguments to commands that take none" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" reboot now + assert_failure + assert_output --partial "reboot takes no arguments" + [[ ! -e "${FIRETV_STATE}/calls.log" ]] +} + +@test "firetv enable undoes a disable" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat + assert_success + assert_output --partial "com.amazon.storm.lightning.tutorial: enabled" + run cat "${FIRETV_STATE}/disabled" + assert_output "" +} + +@test "firetv backup saves the package list under its state dir" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" FIRETV_STATE_DIR="${TEST_TMPDIR}/backups" "${FIRETV}" backup + assert_success + run bash -c 'cat "$1"/packages-*.txt' _ "${TEST_TMPDIR}/backups" + assert_line "package:com.amazon.tv.launcher" +} + +@test "firetv doctor passes a debloated TV and remembers root-only packages" { + setup_firetv_sandbox + export FIRETV_STATE_DIR="${TEST_TMPDIR}/state" + printf '%s\n' com.amazon.tv.launcher com.amazon.tv.settings.v2 >> "${FIRETV_STATE}/installed" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor + assert_success + assert_output --partial "AFTDEC012E, Fire OS 8.1.8.5" + assert_output --partial "warning: telemetry: 0 of 2 disabled" + assert_output --partial "- firetv disable telemetry" + assert_output --partial "warning: No package backup yet" + assert_output --partial "ok: ads: none on this TV" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable telemetry + assert_success + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" backup + assert_success + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor + assert_success + assert_output --partial "ok: telemetry: 1 of 2 disabled" + assert_output --partial "warning: Need root, so still on: com.amazon.device.metrics; Pi-hole not checked" + assert_output --partial "ok: Backup: ${FIRETV_STATE_DIR}/packages-" + refute_output --partial "- firetv disable telemetry" + run grep -- 'disable-user\|pm enable' "${FIRETV_STATE}/calls.log" + assert_output --partial "com.amazon.tv.acr" + run grep -c -- 'disable-user' "${FIRETV_STATE}/calls.log" + assert_output "2" +} + +@test "firetv doctor fails on a disabled core package and says how to restore it" { + setup_firetv_sandbox + printf '%s\n' com.amazon.tv.launcher >> "${FIRETV_STATE}/disabled" + run env PATH="${BIN_SANDBOX}:${PATH}" FIRETV_STATE_DIR="${TEST_TMPDIR}/state" "${FIRETV}" doctor + assert_failure + assert_output --partial "failed: Core packages disabled: com.amazon.tv.launcher" + assert_output --partial "adb -s 192.168.0.84:5555 shell pm enable com.amazon.tv.launcher" +} + +@test "firetv doctor reports a missing adb or an unreachable TV without stopping silently" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" ADB_BIN=no-such-adb "${FIRETV}" doctor + assert_failure + assert_output --partial "failed: adb is not installed" + assert_output --partial "brew install --cask android-platform-tools" + + run env PATH="${BIN_SANDBOX}:${PATH}" ADB_STATE="error: device unauthorized" "${FIRETV}" doctor + assert_failure + assert_output --partial "Accept the prompt on the TV" + assert_output --partial "Failed: 1" +} + +setup_firetv_pihole() { + setup_firetv_sandbox + export PIHOLE_STATE="${TEST_TMPDIR}/pihole" + mkdir -p "${PIHOLE_STATE}" "${FIRETV_STATE_DIR}" + printf '%s\n' com.amazon.ftvads.deeplinking >> "${FIRETV_STATE}/installed" + printf '%s\n' com.amazon.ftvads.deeplinking com.amazon.device.metrics > "${FIRETV_STATE_DIR}/needs-root" + printf '%s\n' mads.amazon.com > "${PIHOLE_STATE}/blocked" + printf '%s\n' msh.amazon.com > "${PIHOLE_STATE}/denylist" + : > "${PIHOLE_STATE}/calls.log" + # ph's --json check and deny, against a deny list in a file. + cat > "${PH_BIN}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${PIHOLE_STATE}/calls.log" +[[ "${1:-}" == --json ]] || { printf 'ph: test expects --json\n' >&2; exit 1; } +shift +case "$1" in + -n) + [[ "$3" == queries && "$4" == 192.168.0.84 ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } + jq -n '{queries: [ + {domain: "appstore-tv-prod-na.amazon.com", status: "FORWARDED"}, + {domain: "appstore-tv-prod-na.amazon.com", status: "CACHE"}, + {domain: "unagi-na.amazon.com", status: "GRAVITY"}, + {domain: "avs-alexa-19-na.amazon.com", status: "FORWARDED"}, + {domain: "mads.amazon.com", status: "GRAVITY"}, + {domain: "aax-us-east.amazon-adsystem.com", status: "FORWARDED"}, + {domain: "api.amazon.com", status: "CACHE"}, + {domain: "bad name;rm", status: "FORWARDED"}]}' + ;; + check) + # A Pi-hole that rate-limits the first login for a domain. + if [[ -n "${PH_LIMIT_ONCE:-}" && ! -e "${PIHOLE_STATE}/limited-$2" ]]; then + touch "${PIHOLE_STATE}/limited-$2" + printf 'ph: login failed: HTTP 429: Rate-limiting login attempts\n' >&2 + exit 1 + fi + if grep -qxF "$2" "${PIHOLE_STATE}/blocked"; then + printf '{"search":{"domains":[],"gravity":[{"domain":"%s","type":"block","enabled":true}]}}\n' "$2" + else + printf '{"search":{"domains":[],"gravity":[]}}\n' + fi + ;; + list) + [[ "$2" == deny ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } + jq -R -s '{domains: (split("\n") | map(select(. != "")) | map({domain: ., kind: "exact", type: "deny", enabled: true}))}' \ + "${PIHOLE_STATE}/denylist" + ;; + deny) + [[ "$2" == -c && -n "$3" ]] || { printf 'ph: test expects a comment\n' >&2; exit 1; } + shift 3 + printf '%s\n' "$@" | tee -a "${PIHOLE_STATE}/blocked" > "${PIHOLE_STATE}/denied" + printf '{"processed":{"success":[],"errors":[]}}\n' + ;; + *) printf 'ph: unexpected %s\n' "$*" >&2; exit 1 ;; +esac +MOCK + chmod +x "${PH_BIN}" +} + +@test "firetv doctor passes on ph's error and says to log in" { + setup_firetv_sandbox + mkdir -p "${FIRETV_STATE_DIR}" + printf '%s\n' com.amazon.ftvads.deeplinking > "${FIRETV_STATE_DIR}/needs-root" + printf '%s\n' com.amazon.ftvads.deeplinking >> "${FIRETV_STATE}/installed" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "Pi-hole not checked: no password: run 'ph login'" + assert_output --partial "- ph login" + + run env PATH="${BIN_SANDBOX}:${PATH}" PH_BIN=no-such-ph "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "Pi-hole not checked: ph not found" +} + +@test "firetv doctor checks the hosts a needs-root package's code looks up and blocks nothing unasked" { + setup_firetv_pihole + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "warning: com.amazon.ftvads.deeplinking needs root; Pi-hole does not block aax-us-east.amazon-adsystem.com" + assert_output --partial "left alone, the TV needs them: api.amazon.com" + assert_output --partial "warning: com.amazon.device.metrics needs root and has no hostnames in its code" + assert_output --partial "- firetv --watch 120 domains com.amazon.device.metrics" + assert_output --partial "- ph deny aax-us-east.amazon-adsystem.com" + refute_output --partial "never-looked-up" + # Only the allowed, non-kept host needs a fresh check; mads is blocked in the log. + run grep -- '--json check' "${PIHOLE_STATE}/calls.log" + assert_output "--json check aax-us-east.amazon-adsystem.com" + run grep -c -- '--json deny' "${PIHOLE_STATE}/calls.log" + assert_output "0" +} + +@test "firetv doctor blocks the unblocked hosts through ph when told yes, then passes" { + setup_firetv_pihole + run bash -c 'printf "1\n" | env PATH="$1" DOTFILES_PROMPT=read "$2" doctor' _ "${BIN_SANDBOX}:${PATH}" "${FIRETV}" + assert_success + assert_output --partial "Pi-hole now blocks aax-us-east.amazon-adsystem.com" + run cat "${PIHOLE_STATE}/denied" + assert_output "aax-us-east.amazon-adsystem.com" + + # The log still says allowed; the fresh check sees the new block. + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "ok: com.amazon.ftvads.deeplinking needs root; Pi-hole blocks what the TV looks up from its code: mads.amazon.com aax-us-east.amazon-adsystem.com" + refute_output --partial "- ph deny" +} + +@test "firetv doctor carries on when a needs-root package's code cannot be read" { + setup_firetv_pihole + printf '%s\n' com.amazon.tv.acr > "${FIRETV_STATE_DIR}/needs-root" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "warning: com.amazon.tv.acr needs root and has no hostnames in its code" + assert_output --partial "Passed: " +} + +@test "firetv doctor fails when the Pi-hole denies a host the TV needs" { + setup_firetv_pihole + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "ok: Pi-hole denies none of the hosts the TV needs" + + printf '%s\n' ktpx.amazon.com >> "${PIHOLE_STATE}/denylist" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_failure + assert_output --partial "failed: Pi-hole denies hosts the TV needs: ktpx.amazon.com" + assert_output --partial "- ph undeny ktpx.amazon.com" +} + +@test "firetv doctor fails when ph cannot add the domains" { + setup_firetv_pihole + sed -i.bak 's/"errors":\[\]/"errors":[{"item":"aax.amazon-adsystem.com","error":"UNIQUE constraint failed"}]/' "${PH_BIN}" + run bash -c 'printf "1\n" | env PATH="$1" DOTFILES_PROMPT=read "$2" doctor' _ "${BIN_SANDBOX}:${PATH}" "${FIRETV}" + assert_failure + assert_output --partial "Pi-hole did not block them: aax.amazon-adsystem.com: UNIQUE constraint failed" +} + +@test "firetv domains needs one plain package name" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" domains + assert_failure + assert_output --partial "domains takes one package" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" domains 'com.x;reboot' + assert_failure + assert_output --partial "Not a package name" + [[ ! -e "${FIRETV_STATE}/calls.log" ]] + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch soon domains com.amazon.venezia + assert_failure + assert_output --partial "--watch needs a number of seconds" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 0 domains com.amazon.nope + assert_failure + assert_output --partial "com.amazon.nope is not on this TV" +} + +@test "firetv domains lists the built-in hosts the TV looked up and names its connections" { + setup_firetv_pihole + cat > "${BIN_SANDBOX}/dig" <<'MOCK' +#!/usr/bin/env bash +case "${*: -1}" in + avs-alexa-19-na.amazon.com) printf 'avs-lb.amazon.com.\n54.167.177.211\n' ;; + appstore-tv-prod-na.amazon.com) printf '52.1.2.3\n' ;; +esac +MOCK + chmod +x "${BIN_SANDBOX}/dig" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 1 domains com.amazon.venezia < /dev/null + assert_success + assert_output --partial "com.amazon.venezia (app ID 10171)" + assert_output --partial "looked up by the TV (2 of 4)" + assert_line --regexp '^ appstore-tv-prod-na\.amazon\.com +allowed +2 lookups$' + assert_line --regexp '^ unagi-na\.amazon\.com +blocked +1 lookups$' + assert_output --partial "2 more never looked up; --all lists them" + refute_output --partial "com.amazon.dev" + refute_output --partial "eu-west-1" + assert_line --regexp '^ 54\.167\.177\.211:443 +avs-alexa-19-na\.amazon\.com$' + refute_output --partial "127.0.0.1" + run grep -c -- '--json check' "${PIHOLE_STATE}/calls.log" + assert_output "0" + + # The rate limit's retry backs off with sleep; skip the wait, not the retry. + printf '#!/bin/sh\n' > "${BIN_SANDBOX}/sleep" + chmod +x "${BIN_SANDBOX}/sleep" + run env PATH="${BIN_SANDBOX}:${PATH}" PH_LIMIT_ONCE=1 "${FIRETV}" --all --watch 0 domains com.amazon.venezia < /dev/null + assert_success + assert_output --partial "Built in, never looked up" + assert_line --regexp '^ ec2\.us-east-1\.amazonaws\.com +not blocked$' + assert_line --regexp '^ juggler\.amazon\.com +not blocked$' + refute_output --regexp ' \?$' + refute_output --partial "Connections in" +} + +@test "firetv domains lists built-in hosts plainly without a Pi-hole log" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 0 domains com.amazon.venezia < /dev/null + assert_success + assert_output --partial "No Pi-hole log (ph login?)" + assert_line " appstore-tv-prod-na.amazon.com" + assert_line " juggler.amazon.com" +} + +@test "firetv animations shows, speeds up, and restores Android's animation scales" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations + assert_success + assert_line --regexp '^ animator_duration_scale +1 \(default\)$' + run grep -c 'settings put' "${FIRETV_STATE}/calls.log" + assert_output "0" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations fast + assert_success + assert_line --regexp '^ window_animation_scale +0\.5$' + assert_line --regexp '^ transition_animation_scale +0\.5$' + assert_line --regexp '^ animator_duration_scale +0\.5$' + assert_output --partial "Animations set to fast (0.5x)" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations normal + assert_success + assert_line --regexp '^ window_animation_scale +1$' + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations ludicrous + assert_failure + assert_output --partial "animations takes fast or normal" +} + +@test "firetv tweak saves what it replaces and off restores it, unset included" { + setup_firetv_sandbox + printf '%s\n' 'global wifi_scan_always_enabled 1' > "${FIRETV_STATE}/settings" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweaks + assert_success + assert_line --regexp '^ no-wifi-scan +off +No Wi-Fi scanning' + assert_line --regexp '^ fast-animations +off +Animations at 0\.5x$' + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-wifi-scan on + assert_success + assert_output --partial "global wifi_scan_always_enabled: 1 -> 0" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan on + assert_success + assert_output --partial "global ble_scan_always_enabled: null -> 0" + # A second on keeps the first saved value. + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-wifi-scan on + assert_success + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweaks + assert_line --regexp '^ no-wifi-scan +on ' + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-wifi-scan off + assert_success + assert_output --partial "global wifi_scan_always_enabled: 0 -> 1 (restored)" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan off + assert_success + run grep -c 'settings delete global ble_scan_always_enabled' "${FIRETV_STATE}/calls.log" + assert_output "1" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan off + assert_success + assert_output --partial "no saved value; left at null" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak warp-speed on + assert_failure + assert_output --partial "Unknown tweak: warp-speed" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan maybe + assert_failure + assert_output --partial "Use: firetv tweak NAME on|off" +} + +@test "firetv tweaks reads your tweaks file and skips unsafe lines" { + setup_firetv_sandbox + export FIRETV_TWEAKS_FILE="${TEST_TMPDIR}/tweaks" + printf '%s\n' '# my tweaks' $'quiet-home\tsecure\tftv_autoplay\t0\tNo autoplay on Home' \ + $'evil\tglobal\tx\t1;reboot\tNope' $'bad-ns\tvendor\tx\t1\tNope' > "${FIRETV_TWEAKS_FILE}" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweaks + assert_success + assert_line --regexp '^ quiet-home +off +No autoplay on Home$' + assert_output --partial "Skipping tweak line: evil global x 1;reboot" + assert_output --partial "Skipping tweak line: bad-ns vendor" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak quiet-home on + assert_success + run grep -c 'settings put secure ftv_autoplay 0' "${FIRETV_STATE}/calls.log" + assert_output "1" + run grep -c reboot "${FIRETV_STATE}/calls.log" + assert_output "0" +} + +@test "firetv settings-diff shows what changed between two runs and suggests tweak lines" { + setup_firetv_sandbox + printf '%s\n' 'secure ftv_autoplay 1' 'global stay_on 0' 'system font_scale 1.0' > "${FIRETV_STATE}/settings" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff + assert_success + assert_output --partial "Saved 3 settings" + assert_output --partial "run firetv settings-diff again" + + printf '%s\n' 'secure ftv_autoplay 0' 'global new_key on' 'system font_scale -' >> "${FIRETV_STATE}/settings" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff + assert_success + assert_line --regexp '^ changed +secure +ftv_autoplay +1 -> 0$' + assert_line --regexp '^ added +global +new_key +\(unset\) -> on$' + assert_line --regexp '^ removed +system +font_scale +1\.0 -> \(unset\)$' + assert_line "$(printf 'my-tweak\tsecure\tftv_autoplay\t0\tDescribe it')" + refute_output --partial $'my-tweak\tsystem' + [[ ! -e "${FIRETV_STATE_DIR}/settings-snapshot" ]] + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff + assert_success + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff reset + assert_success + [[ ! -e "${FIRETV_STATE_DIR}/settings-snapshot" ]] +} + +@test "firetv clean trims caches, and doctor warns when storage is low" { + setup_firetv_sandbox + printf '%s\n' 512000 > "${FIRETV_STATE}/free" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "warning: Storage low: 500 MB free" + assert_output --partial "- firetv clean" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" clean + assert_success + assert_output --partial "App caches trimmed: 200 MB freed, 700 MB free" + + printf '%s\n' 6892824 > "${FIRETV_STATE}/free" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_output --partial "ok: Storage: 6731 MB free" +} + +@test "firetv apps lists installed apps without system overlays, and install sideloads an APK" { + setup_firetv_sandbox + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" apps + assert_success + assert_line --regexp '^ com\.netflix\.ninja +27207$' + refute_output --partial "overlay" + + printf 'not really an apk' > "${TEST_TMPDIR}/org.example.clock.apk" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/org.example.clock.apk" + assert_success + assert_output --partial "Installed org.example.clock.apk" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" apps + assert_line --regexp '^ org\.example\.clock +1$' + + printf 'x' > "${TEST_TMPDIR}/broken.apk" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/broken.apk" + assert_failure + assert_output --partial "Install failed: Failure [INSTALL_PARSE_FAILED_NOT_APK]" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/missing.apk" + assert_failure + assert_output --partial "No such file" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/notes.txt" + assert_failure + assert_output --partial "Not an APK" +} + +@test "firetv uninstall removes only your own apps, and only when told yes" { + setup_firetv_sandbox + printf '%s\n' org.example.clock >> "${FIRETV_STATE}/installed" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall org.example.clock < /dev/null + assert_success + assert_output --partial "org.example.clock kept" + run grep -c ' uninstall org.example.clock$' "${FIRETV_STATE}/calls.log" + assert_output "0" + + run bash -c 'printf "1\n" | env PATH="$1" DOTFILES_PROMPT=read "$2" uninstall org.example.clock' _ "${BIN_SANDBOX}:${PATH}" "${FIRETV}" + assert_success + assert_output --partial "Uninstalled org.example.clock" + run grep -cxF org.example.clock "${FIRETV_STATE}/installed" + assert_output "0" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall com.amazon.tv.launcher < /dev/null + assert_failure + assert_output --partial "core package the TV needs" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall com.amazon.tv.settings.v2.overlay.localisation.common < /dev/null + assert_failure + assert_output --partial "system resource overlay" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall com.amazon.tv.acr < /dev/null + assert_failure + assert_output --partial "built in and can't be uninstalled; firetv disable com.amazon.tv.acr" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall org.example.gone < /dev/null + assert_failure + assert_output --partial "org.example.gone is not on this TV" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall 'x;reboot' < /dev/null + assert_failure + assert_output --partial "Not a package name" + run grep -c -E ' uninstall [^ ]+$' "${FIRETV_STATE}/calls.log" + assert_output "1" +} + +@test "firetv doctor trusts a clean watch of a needs-root package until its version changes" { + setup_firetv_pihole + printf '%s\n' com.amazon.device.metrics > "${FIRETV_STATE_DIR}/needs-root" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --partial "warning: com.amazon.device.metrics needs root and has no hostnames in its code; watch what it connects to" + + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 1 domains com.amazon.device.metrics < /dev/null + assert_success + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_success + assert_output --regexp "ok: com\.amazon\.device\.metrics needs root; no hostnames in its code, no connections in a 1s watch on [0-9]{4}-[0-9]{2}-[0-9]{2}" + refute_output --partial "domains com.amazon.device.metrics" + + printf '%s\n' 2 > "${FIRETV_STATE}/metrics-version" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_output --partial "warning: com.amazon.device.metrics needs root and changed version since the watch on" + assert_output --partial "- firetv --watch 120 domains com.amazon.device.metrics" + + touch "${FIRETV_STATE}/metrics-connects" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 1 domains com.amazon.device.metrics < /dev/null + assert_success + assert_output --partial "54.167.177.211:443" + run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null + assert_output --partial "warning: com.amazon.device.metrics needs root and made connections in the watch on" + run grep -c com.amazon.device.metrics "${FIRETV_STATE_DIR}/watched" + assert_output "1" +} diff --git a/tests/bin_ph_backup.bats b/tests/bin_ph_backup.bats new file mode 100644 index 0000000..a17f837 --- /dev/null +++ b/tests/bin_ph_backup.bats @@ -0,0 +1,550 @@ +#!/usr/bin/env bats +# Tests for ph-backup against a scratch Pi-hole, Unbound, and Tailscale. + +load test_helper + +setup() { + # Copies of the tools made by the tests find the shared helpers here. + export DOTFILES_DIR="${PROJECT_ROOT}" + setup_tmpdir + export BIN_SANDBOX="${TEST_TMPDIR}/bin" + mkdir -p "${BIN_SANDBOX}" +} + +teardown() { + teardown_tmpdir +} + +@test "ph-backup displays help" { + run "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" --help + assert_success + assert_output --partial "Usage:" + assert_output --partial "ph-backup [-o|--output DIR]" + assert_output --partial "~/backups/ph-backup" +} + +@test "ph-backup rejects unknown arguments before elevating" { + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" +MOCK + + chmod +x "${BIN_SANDBOX}/sudo" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" --bogus + assert_failure + assert_output --partial "Unknown argument: --bogus" + refute_output --partial "sudo " +} + +@test "ph-backup refuses to run off Linux" { + run env OSTYPE="darwin23" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" + assert_failure + assert_output --partial "only runs on the Linux host" +} + +@test "ph-backup self-elevates through sudo and preserves the output directory" { + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" +MOCK + + chmod +x "${BIN_SANDBOX}/sudo" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" -o /mnt/nas/pihole + assert_success + refute_output --partial "PATH=" + assert_output --partial "sudo ${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup -o /mnt/nas/pihole" +} + +# ph-backup must run as root, so tests exercise a copy with the self-elevation +# block stripped out. Mocks stand in for Pi-hole, Unbound, Tailscale, and the +# GNU flags (stat -c, numfmt) that the Linux host provides but macOS does not. +setup_ph_backup_sandbox() { + local teleporter_status="${1:-0}" + + cat > "${BIN_SANDBOX}/pihole-FTL" < etc/pihole/pihole.toml + printf 'hosts\n' > etc/hosts + zip -qr "pi-hole_pi_teleporter_2026-01-01.zip" etc + rm -rf etc + printf 'pi-hole_pi_teleporter_2026-01-01.zip\n' + ;; + --config) printf '["127.0.0.1#5335"]\n' ;; + sqlite3) + case "\${3}" in + *gravity*) printf '8843595\n' ;; + *) printf '9\n' ;; + esac + ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/pihole" <<'MOCK' +#!/usr/bin/env bash +[[ "${1}" == "-v" ]] && printf 'Core version is v6.1.4\n' +MOCK + + # Peer "m" carries Mullvad's shared-exit-node tag and must be filtered out. + cat > "${BIN_SANDBOX}/tailscale" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1}" == "status" && "${2}" == "--json" ]]; then + printf '{"Version":"1.90.2","BackendState":"Running","MagicDNSSuffix":"tail1.ts.net",' + printf '"CurrentTailnet":{"Name":"example.com"},' + printf '"Self":{"HostName":"pi","TailscaleIPs":["100.64.0.5"]},' + printf '"Peer":{"a":{"HostName":"mac","Online":true},"b":{"HostName":"nas","Online":false},' + printf '"m":{"HostName":"mullvad-lax","Online":true,"Tags":["tag:mullvad-exit-node"]}}}\n' +fi +MOCK + + cat > "${BIN_SANDBOX}/unbound-checkconf" < "${BIN_SANDBOX}/sqlite3" <<'MOCK' +#!/usr/bin/env bash +case "${2}" in + *gravity*) printf '128432\n' ;; + *) printf '7\n' ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/hostname" <<'MOCK' +#!/usr/bin/env bash +printf 'pi\n' +MOCK + + cat > "${BIN_SANDBOX}/stat" <<'MOCK' +#!/usr/bin/env bash +[[ "${1}" == "-c" && "${2}" == "%s" ]] && exec /usr/bin/stat -f%z "${3}" +exec /usr/bin/stat "$@" +MOCK + + chmod +x "${BIN_SANDBOX}/pihole-FTL" "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/tailscale" \ + "${BIN_SANDBOX}/unbound-checkconf" "${BIN_SANDBOX}/sqlite3" "${BIN_SANDBOX}/hostname" \ + "${BIN_SANDBOX}/stat" + + mkdir -p "${TEST_TMPDIR}/etc/unbound/unbound.conf.d" "${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/var/lib/unbound" "${TEST_TMPDIR}/out" + printf 'server:\n port: 5335\n' > "${TEST_TMPDIR}/etc/unbound/unbound.conf.d/pi-hole.conf" + printf 'gravity\n' > "${TEST_TMPDIR}/etc/pihole/gravity.db" + # The trust anchor lives outside the config directory, as on Debian. + printf 'anchor\n' > "${TEST_TMPDIR}/var/lib/unbound/root.key" + + awk ' + /^# Self-elevate if not root/ { skip = 1 } + skip && /^fi$/ { skip = 0; next } + skip { next } + { print } + ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" > "${TEST_TMPDIR}/ph-backup" + + chmod +x "${TEST_TMPDIR}/ph-backup" +} + +run_ph_backup_probe() { + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + PH_BACKUP_SBIN_PATHS="" \ + PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ + PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" +} + +# One run of the default sandbox, shared by the tests that only read its +# output and archive; PH_BACKUP_RUN stands in for their TEST_TMPDIR. +setup_file() { + local TEST_TMPDIR="${BATS_FILE_TMPDIR}/run" BIN_SANDBOX="${BATS_FILE_TMPDIR}/run/bin" + export DOTFILES_DIR="${PROJECT_ROOT}" + mkdir -p "${BIN_SANDBOX}" + setup_ph_backup_sandbox + run_ph_backup_probe + printf '%s' "${status}" > "${TEST_TMPDIR}/status" + printf '%s' "${output}" > "${TEST_TMPDIR}/output" + export PH_BACKUP_RUN="${TEST_TMPDIR}" +} + +# Loads the shared run's status and output, and its archive into $archive. +load_ph_backup_run() { + status="$(cat "${PH_BACKUP_RUN}/status")" + output="$(cat "${PH_BACKUP_RUN}/output")" + archive="$(find "${PH_BACKUP_RUN}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" +} + +@test "ph-backup archives the three backups plus a summary" { + local archive + load_ph_backup_run + assert_success + assert_output --partial "Teleporter archive captured" + assert_output --partial "Tailscale state captured" + assert_output --partial "SUMMARY.md written" + assert_output --partial "Failed: 0" + [[ -n "${archive}" ]] + + run tar -tzf "${archive}" + assert_success + assert_output --partial "SUMMARY.md" + assert_output --partial "pi-hole_pi_teleporter_2026-01-01.zip" + assert_output --partial "unbound-" + assert_output --partial "tailscale-status-" +} + +@test "ph-backup summary describes all three sources" { + local archive + load_ph_backup_run + assert_success + + run tar -xzOf "${archive}" '*/SUMMARY.md' + assert_success + assert_output --partial "Core version is v6.1.4" + assert_output --partial "Gravity domains: 128432" + assert_output --partial '127.0.0.1#5335' + assert_output --partial "Backend state: Running" + assert_output --partial "Tailnet: example.com" + assert_output --partial "Peers: 2 total, 1 online" + assert_output --partial "Keep it private" +} + +@test "ph-backup maps every archived path to a restore destination" { + local archive + load_ph_backup_run + assert_success + + run tar -xzOf "${archive}" '*/SUMMARY.md' + assert_success + assert_output --partial "Restore map" + + # Every path inside the Teleporter zip and the Unbound tarball is listed + # with the absolute path it belongs at. + assert_output --partial '| `etc/pihole/pihole.toml` | `/etc/pihole/pihole.toml` |' + assert_output --partial '| `etc/hosts` | `/etc/hosts` |' + + # Unbound paths are stored relative to / so each maps to one destination. + local rel="${PH_BACKUP_RUN#/}" + assert_output --partial '| `'"${rel}"'/etc/unbound/unbound.conf.d/pi-hole.conf` | `'"${PH_BACKUP_RUN}"'/etc/unbound/unbound.conf.d/pi-hole.conf` |' + assert_output --partial '| `'"${rel}"'/var/lib/unbound/root.key` | `'"${PH_BACKUP_RUN}"'/var/lib/unbound/root.key` |' + + # Artifacts that restore nowhere say so outright. + assert_output --partial "Restores to: nothing" +} + +@test "ph-backup contents table names a destination for every file" { + setup_ph_backup_sandbox + printf 'server:\n' > "${TEST_TMPDIR}/etc/unbound/unbound.conf" + + run_ph_backup_probe + assert_success + + local archive + archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" + + run tar -xzOf "${archive}" '*/SUMMARY.md' + assert_success + assert_output --partial "| File | Size | Restores to |" + + # Teleporter keeps /etc and /etc/pihole distinct; a top-level directory is + # too broad to stand in for its children. + assert_output --partial '| `/etc`, `/etc/pihole` |' + + # Unbound collapses its subdirectory into the config root. + assert_output --partial '`'"${TEST_TMPDIR}"'/etc/unbound`, `'"${TEST_TMPDIR}"'/var/lib/unbound` |' + + # The two files that restore nowhere are listed, not omitted. + assert_output --partial "| nothing — reference only |" + assert_output --partial '| `SUMMARY.md` | — | nothing — this file |' + + # Checksums survive the move out of the contents table. + assert_output --partial "## Checksums" + assert_output --partial "sha256sum -c" +} + +@test "ph-backup includes the DNSSEC trust anchor from outside the config dir" { + local archive inner="${TEST_TMPDIR}/inner" + load_ph_backup_run + assert_success + mkdir -p "${inner}" + tar -xzf "${archive}" -C "${inner}" + + run tar -tzf "$(find "${inner}" -name 'unbound-*.tar.gz' -print -quit)" + assert_success + assert_output --partial "${PH_BACKUP_RUN#/}/etc/unbound/unbound.conf.d/pi-hole.conf" + assert_output --partial "${PH_BACKUP_RUN#/}/var/lib/unbound/root.key" +} + +@test "ph-backup drops Mullvad exit nodes from the Tailscale state" { + local archive inner="${TEST_TMPDIR}/inner" state + load_ph_backup_run + assert_success + mkdir -p "${inner}" + tar -xzf "${archive}" -C "${inner}" + state="$(find "${inner}" -name 'tailscale-status-*.json' -print -quit)" + + run jq -r '.Peer | keys | join(",")' "${state}" + assert_success + assert_output "a,b" + + run tar -xzOf "${archive}" '*/SUMMARY.md' + assert_output --partial "Peers: 2 total" + assert_output --partial "Filtered out: 1 shared Mullvad exit nodes" +} + +@test "ph-backup copies the archive off-box when a destination is given" { + setup_ph_backup_sandbox + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + PH_BACKUP_SBIN_PATHS="" \ + PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ + PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" --copy-to "${TEST_TMPDIR}/offbox" + assert_success + assert_output --partial "Archive copied to ${TEST_TMPDIR}/offbox" + + run find "${TEST_TMPDIR}/offbox" -name 'ph-backup-pi-*.tar.gz' + assert_output --partial "ph-backup-pi-" +} + +@test "ph-backup warns without failing when the off-box copy cannot be written" { + setup_ph_backup_sandbox + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + PH_BACKUP_SBIN_PATHS="" \ + PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ + PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" \ + --copy-to "${TEST_TMPDIR}/etc/pihole/gravity.db/nope" + assert_success + assert_output --partial "Could not copy the archive" + assert_output --partial "Warnings: 1" +} + +@test "ph-backup reads gravity stats through pihole-FTL when sqlite3 cannot" { + setup_ph_backup_sandbox + + # The Pi has no sqlite3 package at all; a stub that returns nothing stands + # in for that, since the host running these tests has its own sqlite3. + cat > "${BIN_SANDBOX}/sqlite3" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK + + chmod +x "${BIN_SANDBOX}/sqlite3" + + run_ph_backup_probe + assert_success + + local archive + archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" + + run tar -xzOf "${archive}" '*/SUMMARY.md' + assert_success + assert_output --partial "Gravity domains: 8843595" + assert_output --partial "Enabled adlists: 9" + refute_output --partial "Gravity statistics: unavailable" +} + +@test "ph-backup warns but still archives when Tailscale and Unbound are missing" { + setup_ph_backup_sandbox + rm "${BIN_SANDBOX}/tailscale" + rm -rf "${TEST_TMPDIR}/etc/unbound" + + run_ph_backup_probe + assert_success + assert_output --partial "Could not capture Tailscale state" + assert_output --partial "Could not archive" + assert_output --partial "Warnings: 2" + + local archive + archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" + [[ -n "${archive}" ]] +} + +@test "ph-backup writes no archive when the Teleporter export fails" { + setup_ph_backup_sandbox 1 + + run_ph_backup_probe + assert_failure + assert_output --partial "Teleporter export failed" + assert_output --partial "Run pihole-FTL --teleporter manually" + + run find "${TEST_TMPDIR}/out" -name '*.tar.gz' + assert_output "" +} + +@test "ph-backup as root searches only system directories, not the caller's PATH" { + local probe_script="${TEST_TMPDIR}/ph-backup-root" + local dir + + for dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do + [[ ! -e "${dir}/pihole-FTL" && ! -e "${dir}/pihole" ]] || skip "Pi-hole is installed on this host" + done + + # Take the root branch without being root. + sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" > "${probe_script}" + chmod +x "${probe_script}" + + local cmd + for cmd in pihole-FTL pihole hostname tar gum sudo; do + printf '#!/usr/bin/env bash\ntouch "%s/user-path-used"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/${cmd}" + chmod +x "${BIN_SANDBOX}/${cmd}" + done + mkdir -p "${TEST_TMPDIR}/out" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + "${probe_script}" -o "${TEST_TMPDIR}/out" + assert_failure + assert_output --partial "neither pihole-FTL nor pihole is on PATH" + [[ ! -e "${TEST_TMPDIR}/user-path-used" ]] +} + +# Pin the snapshot timestamp so tests can plant files at the archive name. +mock_ph_backup_date() { + cat > "${BIN_SANDBOX}/date" <<'MOCK' +#!/usr/bin/env bash +[[ "${1:-}" == "+%Y%m%d-%H%M%S" ]] && { printf '20260101-000000\n'; exit 0; } +exec /bin/date "$@" +MOCK + chmod +x "${BIN_SANDBOX}/date" +} + +@test "ph-backup creates a missing output directory owner-only with a 0600 archive" { + setup_ph_backup_sandbox + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + PH_BACKUP_SBIN_PATHS="" \ + PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ + PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/fresh/backups" + assert_success + + run find "${TEST_TMPDIR}/fresh/backups" -maxdepth 0 -perm 0700 + assert_output "${TEST_TMPDIR}/fresh/backups" + + run find "${TEST_TMPDIR}/fresh/backups" -name 'ph-backup-pi-*.tar.gz' -perm 0600 + assert_output --partial "ph-backup-pi-" + + # The temporary file was renamed into place, not left behind. + run find "${TEST_TMPDIR}/fresh/backups" -name '.tmp.*' + assert_output "" +} + +@test "ph-backup refuses a symlinked or shared-writable output directory" { + setup_ph_backup_sandbox + mkdir -p "${TEST_TMPDIR}/real-out" + ln -s "${TEST_TMPDIR}/real-out" "${TEST_TMPDIR}/link-out" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + PH_BACKUP_SBIN_PATHS="" \ + PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ + PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/link-out" + assert_failure + assert_output --partial "Refusing ${TEST_TMPDIR}/link-out: it is a symlink" + assert_output --partial "Could not write the backup archive" + + run find "${TEST_TMPDIR}/real-out" -type f + assert_output "" + + chmod 777 "${TEST_TMPDIR}/out" + run_ph_backup_probe + assert_failure + assert_output --partial "it is writable by group or others" + + run find "${TEST_TMPDIR}/out" -type f + assert_output "" +} + +@test "ph-backup never writes through a symlink planted at the archive name" { + setup_ph_backup_sandbox + mock_ph_backup_date + printf 'victim\n' > "${TEST_TMPDIR}/victim" + ln -s "${TEST_TMPDIR}/victim" "${TEST_TMPDIR}/out/ph-backup-pi-20260101-000000.tar.gz" + + run_ph_backup_probe + assert_failure + assert_output --partial "Refusing to replace existing" + + run cat "${TEST_TMPDIR}/victim" + assert_output "victim" + [[ -L "${TEST_TMPDIR}/out/ph-backup-pi-20260101-000000.tar.gz" ]] + + run find "${TEST_TMPDIR}/out" -name '.tmp.*' + assert_output "" +} + +@test "ph-backup off-box copy never writes through a planted symlink" { + setup_ph_backup_sandbox + mock_ph_backup_date + mkdir -p "${TEST_TMPDIR}/offbox" + printf 'victim\n' > "${TEST_TMPDIR}/victim" + ln -s "${TEST_TMPDIR}/victim" "${TEST_TMPDIR}/offbox/ph-backup-pi-20260101-000000.tar.gz" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + PH_BACKUP_SBIN_PATHS="" \ + PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ + PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" --copy-to "${TEST_TMPDIR}/offbox" + assert_success + assert_output --partial "Could not copy the archive" + + run cat "${TEST_TMPDIR}/victim" + assert_output "victim" + + run find "${TEST_TMPDIR}/offbox" -name '.tmp.*' + assert_output "" +} + +@test "ph-backup writes the archive and scp copy as the invoking user" { + setup_ph_backup_sandbox + mkdir -p "${TEST_TMPDIR}/remote" + + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" +[[ "${1:-}" == "-u" ]] && shift 2 +exec "$@" +MOCK + + # Stand in for the remote end, and report what scp was allowed to read. + cat > "${BIN_SANDBOX}/scp" <<'MOCK' +#!/usr/bin/env bash +src="${2}" +[[ -O "${src}" ]] && printf 'scp source owned by caller\n' +[[ -n "$(find "${src}" -maxdepth 0 -perm 0600)" ]] && printf 'scp source mode 0600\n' +cp "${src}" "${TEST_TMPDIR}/remote/" +MOCK + + chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/scp" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" TEST_TMPDIR="${TEST_TMPDIR}" \ + SUDO_USER="$(id -un)" \ + PH_BACKUP_SBIN_PATHS="" \ + PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ + PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" --copy-to nas:/backups + assert_success + assert_output --partial "Archive copied to nas:/backups" + # The archive lands through a shell run as the invoking user, never root. + assert_output --partial "sudo -u $(id -un) sh -c" + assert_output --regexp "sudo -u $(id -un) scp -q ${TEST_TMPDIR}/out/ph-backup-pi-[^ ]*\.tar\.gz nas:/backups" + assert_output --partial "scp source owned by caller" + assert_output --partial "scp source mode 0600" + + run find "${TEST_TMPDIR}/remote" -name 'ph-backup-pi-*.tar.gz' + assert_output --partial "ph-backup-pi-" +} diff --git a/tests/bin_pihole.bats b/tests/bin_pihole.bats new file mode 100644 index 0000000..841597e --- /dev/null +++ b/tests/bin_pihole.bats @@ -0,0 +1,1482 @@ +#!/usr/bin/env bats +# Tests for the Pi-hole host scripts ph-sec-audit, ph-padd, ph-update, and ph-test, with their external commands mocked. + +load test_helper + +setup() { + # Copies of the tools made by the tests find the shared helpers here. + export DOTFILES_DIR="${PROJECT_ROOT}" + setup_tmpdir + export BIN_SANDBOX="${TEST_TMPDIR}/bin" + mkdir -p "${BIN_SANDBOX}" + cat > "${BIN_SANDBOX}/brew" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + --cache) printf '%s\n' "${TEST_TMPDIR}/brew-cache" ;; + *) printf 'brew %s\n' "$*" ;; +esac +MOCK + chmod +x "${BIN_SANDBOX}/brew" +} + +teardown() { + teardown_tmpdir +} + +@test "ph-sec-audit defaults to a plan without applying changes" { + run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" + assert_success + assert_output --partial "Without --apply" + refute_output --partial "Backups:" + + run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --upgrade-packages + assert_success + assert_output --partial "Without --apply" + refute_output --partial "Backups:" +} + +@test "ph-sec-audit requires a target and rejects malformed arguments" { + run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --apply + assert_failure + assert_output --partial "requires an explicit valid --user and --host" + + run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --apply --user --host + assert_failure + assert_output --partial "Missing value" + + run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --unknown + assert_failure + assert_output --partial "Unknown argument" +} + +@test "ph-sec-audit refuses a different host before making changes" { + run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" \ + --apply --user operator --host "$(hostname)-wrong-target" + assert_failure + assert_output --partial "Host mismatch; no changes made" + refute_output --partial "Backups:" +} + +run_remediation_web_probe() { + local probe_script="${TEST_TMPDIR}/web-probe.sh" + + cat > "${probe_script}" <<'PROBE' +#!/usr/bin/env bash +set -euo pipefail +old_ports='original-ports' +backup_dir="${TEST_TMPDIR}" +pihole-FTL() { + printf '%s\n' "${3}" >> "${TEST_TMPDIR}/port-changes" +} +systemctl() { + if [[ ! -f "${TEST_TMPDIR}/restart-attempted" ]]; then + touch "${TEST_TMPDIR}/restart-attempted" + case "${PROBE_FAILURE}" in + restart) return 7 ;; + interrupt) kill -TERM "$$" ;; + esac + fi +} +curl() { return 1; } +sleep() { :; } +PROBE + awk ' + /^restore_web_ports\(\) \{/ { copy=1 } + copy { print } + /^trap - ERR HUP INT TERM$/ { exit } + ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" >> "${probe_script}" + + run env PROBE_FAILURE="${1}" bash "${probe_script}" +} + +@test "ph-sec-audit restores web ports when verification fails" { + run_remediation_web_probe verification + assert_failure + assert_output --partial "restoring original port configuration" + run tail -n 1 "${TEST_TMPDIR}/port-changes" + assert_success + assert_output "original-ports" +} + +@test "ph-sec-audit restores web ports and preserves restart failure status" { + run_remediation_web_probe restart + assert_equal "${status}" 7 + assert_output --partial "restoring original port configuration" + run tail -n 1 "${TEST_TMPDIR}/port-changes" + assert_success + assert_output "original-ports" +} + +@test "ph-sec-audit restores web ports when interrupted" { + run_remediation_web_probe interrupt + assert_equal "${status}" 143 + assert_output --partial "restoring original port configuration" + run tail -n 1 "${TEST_TMPDIR}/port-changes" + assert_success + assert_output "original-ports" +} + +# Runs ph-sec-audit's report_web_password with pihole-FTL answering $1. +run_web_password_probe() { + local probe_script="${TEST_TMPDIR}/password-probe.sh" + + { + printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \ + 'pihole-FTL() { printf "%s\n" "${PROBE_HASH}"; }' + awk ' + /^report_web_password\(\) \{/ { copy=1 } + copy { print } + copy && /^}$/ { exit } + ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" + printf '%s\n' 'report_web_password' + } > "${probe_script}" + + run env PROBE_HASH="${1}" bash "${probe_script}" +} + +@test "ph-sec-audit reports whether a web password is set" { + run_web_password_probe '$BALLOON-SHA256$v=1$fakehash' + assert_success + assert_output "A web password is set." + + run_web_password_probe '""' + assert_success + assert_output --partial "No web password is set" + assert_output --partial "sudo pihole setpassword" + + run_web_password_probe '' + assert_success + assert_output --partial "No web password is set" +} + +run_padd_api_probe() { + local source_script="${1}" + local probe_script="${TEST_TMPDIR}/$(basename "${source_script}")" + + awk ' + /^main\(\)\{$/ { + print "main(){" + print " xOffset=0" + print " TestAPIAvailability" + print "}" + skip=1 + next + } + skip && /^}$/ { + skip=0 + next + } + skip { + next + } + { print } + ' "${source_script}" > "${probe_script}" + + chmod +x "${probe_script}" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" +} + +run_padd_dns_unbound_probe() { + local source_script="${1}" + local probe_script="${TEST_TMPDIR}/$(basename "${source_script}")" + + awk ' + /^main\(\)\{$/ { + print "main(){" + print " connection_down_flag=true" + print " GetNetworkInformation" + print " printf '\''status=%s\\n'\'' \"${unbound_status}\"" + print " printf '\''listener=%s\\n'\'' \"${unbound_listener}\"" + print " printf '\''dnssec=%s\\n'\'' \"${unbound_dnssec_status}\"" + print " printf '\''cache=%s\\n'\'' \"${unbound_cache_status}\"" + print "}" + skip=1 + next + } + skip && /^}$/ { + skip=0 + next + } + skip { + next + } + { print } + ' "${source_script}" > "${probe_script}" + + chmod +x "${probe_script}" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" +} + +@test "ph-padd displays help" { + run sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-padd" --help + assert_success + assert_output --partial "PADD displays stats about your Pi-hole" + assert_output --partial "--api" + assert_output --partial "--runonce" +} + +@test "ph-padd startup probe fails fast when no API URLs are discovered" { + cat > "${BIN_SANDBOX}/dig" < "${TEST_TMPDIR}/ph-padd-dig-args" +exit 0 +MOCK + chmod +x "${BIN_SANDBOX}/dig" + + run_padd_api_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-padd" + assert_failure + assert_output --partial "API not available at: localhost" + + run cat "${TEST_TMPDIR}/ph-padd-dig-args" + assert_success + assert_output --partial "+time=2" + assert_output --partial "+tries=1" +} + +@test "ph-padd surfaces unbound listener and cache details" { + local main_conf="${TEST_TMPDIR}/unbound.conf" + local include_conf="${TEST_TMPDIR}/pi-hole.conf" + + cat > "${main_conf}" <<'CONF' +remote-control: + control-enable: yes +CONF + + cat > "${include_conf}" <<'CONF' +server: + interface: 127.0.0.1 + port: 5335 + harden-dnssec-stripped: yes +CONF + + cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' +#!/usr/bin/env bash +case "${1:-}" in + status) + printf 'unbound is running as pid 1234\n' + ;; + stats_noreset) + printf 'total.num.queries=200\n' + printf 'total.num.cachehits=150\n' + ;; +esac +MOCK + + chmod +x "${BIN_SANDBOX}/unbound-control" + + export PH_PADD_UNBOUND_MAIN_CONF="${main_conf}" + export PH_PADD_UNBOUND_CONF="${include_conf}" + + run_padd_dns_unbound_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-padd" + + assert_success + assert_output --partial "status=Running" + assert_output --partial "listener=127.0.0.1:5335" + assert_output --partial "dnssec=Hardened" + assert_output --partial "cache=75% hit" + + unset PH_PADD_UNBOUND_MAIN_CONF + unset PH_PADD_UNBOUND_CONF +} + +@test "ph-padd does not splice unbound stats into awk code" { + local marker="${TEST_TMPDIR}/awk-ran" + + cat > "${BIN_SANDBOX}/unbound-control" < "${probe_script}" + chmod +x "${probe_script}" + + rm -f "${TEST_TMPDIR}/curl-args" "${TEST_TMPDIR}/curl-stdin" + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" "$@" +} + +# curl mock: logs argv and stdin separately and prints curl-body (plus the +# >>status suffix when called with -w). dig mock prints dig-reply. +write_padd_network_mocks() { + ln -sf "$(command -v jq)" "${BIN_SANDBOX}/jq" + + cat > "${BIN_SANDBOX}/curl" <> "${TEST_TMPDIR}/curl-args" +for arg in "\$@"; do + case "\${arg}" in + -|@-) cat >> "${TEST_TMPDIR}/curl-stdin"; printf '\n' >> "${TEST_TMPDIR}/curl-stdin" ;; + esac +done +cat "${TEST_TMPDIR}/curl-body" 2>/dev/null +for arg in "\$@"; do + [[ "\${arg}" == "-w" ]] && printf '>>200' +done +exit 0 +MOCK + + cat > "${BIN_SANDBOX}/dig" </dev/null +MOCK + chmod +x "${BIN_SANDBOX}/curl" "${BIN_SANDBOX}/dig" +} + +@test "ph-padd scripts never evaluate API numbers in shell arithmetic" { + local marker="${TEST_TMPDIR}/pwned" script + write_padd_network_mocks + printf '{"system":{"uptime":"a[$(touch %s)]"}}' "${marker}" > "${TEST_TMPDIR}/curl-body" + + for script in ph-padd; do + PADD_PROBE_MAIN='API_URL=https://127.0.0.1/api/; GetPADDData; convertUptime "$(GetPADDValue system.uptime)"; echo; convertUptime 90061' \ + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" + assert_success + assert_output --partial "0 days, 00 hours, 00 minutes" + assert_output --partial "1 days, 01 hours, 01 minutes" + assert [ ! -e "${marker}" ] + done +} + +@test "ph-padd scripts strip control characters from API strings" { + local script + write_padd_network_mocks + printf '%s' '{"node_name":"evil\u001b]0;t\u0007\nsystem.uptime=5"}' > "${TEST_TMPDIR}/curl-body" + + for script in ph-padd; do + PADD_PROBE_MAIN='API_URL=https://127.0.0.1/api/; GetPADDData; printf "host=%s|uptime=%s\n" "$(GetPADDValue node_name)" "$(GetPADDValue system.uptime)"' \ + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" + assert_success + assert_output "host=evil]0;tsystem.uptime=5|uptime=" + done +} + +@test "ph-padd scripts verify TLS for remote servers and pin discovered URLs to --server" { + local script + write_padd_network_mocks + printf '%s\n' '"https://attacker.example/api/" "http://attacker.example:80/api/"' > "${TEST_TMPDIR}/dig-reply" + : > "${TEST_TMPDIR}/ca.pem" + PADD_PROBE_MAIN='TestAPIAvailability; printf "api=%s\n" "${API_URL}"' + + for script in ph-padd; do + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 + assert_success + assert_output --partial "api=https://192.0.2.10/api/" + run cat "${TEST_TMPDIR}/curl-args" + assert_output --partial "https://192.0.2.10/api/auth" + refute_output --partial "--insecure" + refute_output --regexp '(^| )-[a-zA-Z]*k' + + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 --cacert "${TEST_TMPDIR}/ca.pem" + assert_success + run cat "${TEST_TMPDIR}/curl-args" + assert_output --partial "--cacert ${TEST_TMPDIR}/ca.pem" + refute_output --partial "--insecure" + + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 --insecure + assert_success + run cat "${TEST_TMPDIR}/curl-args" + assert_output --partial "--insecure" + + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 127.0.0.1 + assert_success + assert_output --partial "api=https://127.0.0.1/api/" + run cat "${TEST_TMPDIR}/curl-args" + assert_output --partial "--insecure" + done +} + +@test "ph-padd scripts refuse plain http API URLs for remote hosts" { + local script + write_padd_network_mocks + printf '%s\n' '"http://attacker.example:80/api/"' > "${TEST_TMPDIR}/dig-reply" + PADD_PROBE_MAIN='TestAPIAvailability; printf "api=%s\n" "${API_URL}"' + + for script in ph-padd; do + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 + assert_failure + assert_output --partial "Refusing plain http:// API URL http://192.0.2.10:80/api/" + assert [ ! -e "${TEST_TMPDIR}/curl-args" ] + + run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --api http://192.0.2.10/api/ + assert_failure + assert_output --partial "Refusing API URL http://192.0.2.10/api/" + assert [ ! -e "${TEST_TMPDIR}/curl-args" ] + done +} + +@test "ph-padd scripts keep the password and session ID out of curl argv" { + local script + write_padd_network_mocks + printf '%s' '{"session":{"valid":true,"sid":"c2Vzc2lvbg=="}}' > "${TEST_TMPDIR}/curl-body" + PADD_PROBE_MAIN='API_URL=https://192.0.2.10/api/; Authenticate; printf "valid=%s\n" "${validSession}"; GetFTLData info/ftl >/dev/null' + + for script in ph-padd; do + PADD_PASSWORD='hunter2"\x' run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" + assert_success + assert_output --partial "valid=true" + + run cat "${TEST_TMPDIR}/curl-args" + refute_output --partial "hunter2" + refute_output --partial "c2Vzc2lvbg" + + run cat "${TEST_TMPDIR}/curl-stdin" + assert_output --partial '{"password":"hunter2\"\\x","totp":null}' + assert_output --partial 'header = "sid: c2Vzc2lvbg=="' + done +} + +@test "ph-padd scripts reject --secret and skip self-update" { + local script + + for script in ph-padd; do + run sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --secret hunter2 + assert_failure + assert_output --partial "PADD_PASSWORD" + + # -u never downloads: the file comes from the repo + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --update + assert_success + assert_output "ph-padd is managed by ~/.dotfiles; update it there." + + run sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --help + assert_output --partial "--cacert" + assert_output --partial "--insecure" + refute_output --partial "--secret" + done +} + +@test "ph-update displays help" { + run "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" --help + assert_success + assert_output --partial "Usage:" + assert_output --partial "ph-update" + assert_output --partial "ph-update -r" + assert_output --partial "Run os-doctor, refresh Pi-hole, and optionally restart Linux" +} + +@test "ph-update runs os-doctor before self-elevating through sudo" { + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/apt-get" <<'MOCK' +#!/usr/bin/env bash +printf 'apt-get %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/pihole" <<'MOCK' +#!/usr/bin/env bash +printf 'pihole %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/ph-padd" <<'MOCK' +#!/usr/bin/env bash +printf 'ph-padd %s\n' "$*" +MOCK + + chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/apt-get" "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" + assert_success + assert_output --partial "sudo apt-get update" + refute_output --partial "PATH=" + assert_output --partial "sudo PH_UPDATE_SKIP_OS_UPDATE=1 ${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" +} + +@test "ph-update preserves -r through self-elevating sudo" { + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/apt-get" <<'MOCK' +#!/usr/bin/env bash +printf 'apt-get %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/pihole" <<'MOCK' +#!/usr/bin/env bash +printf 'pihole %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/ph-padd" <<'MOCK' +#!/usr/bin/env bash +printf 'ph-padd %s\n' "$*" +MOCK + + chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/apt-get" "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" -r + assert_success + assert_output --partial "PH_UPDATE_SKIP_OS_UPDATE=1" + assert_output --partial "ph-update -r" +} + +@test "ph-update rejects -r on non-linux hosts" { + run env OSTYPE="darwin23" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" -r + assert_failure + assert_output --partial "-r/--restart is only supported on Linux" +} + +@test "ph-update restarts Linux after a successful run when -r is set" { + local probe_script="${TEST_TMPDIR}/ph-update" + ln -sf "${PROJECT_ROOT}/stow/tools/.local/bin/os-doctor" "${TEST_TMPDIR}/os-doctor" + + sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" > "${probe_script}" + chmod +x "${probe_script}" + + cat > "${BIN_SANDBOX}/pihole" <<'MOCK' +#!/usr/bin/env bash +printf 'pihole %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/ph-padd" <<'MOCK' +#!/usr/bin/env bash +printf 'ph-padd %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/shutdown" <<'MOCK' +#!/usr/bin/env bash +printf 'shutdown %s\n' "$*" +MOCK + + chmod +x "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" "${BIN_SANDBOX}/shutdown" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" PH_UPDATE_SKIP_OS_UPDATE=1 \ + DOTFILES_DIR="${PROJECT_ROOT}" "${probe_script}" -r + assert_success + assert_output --partial "Pi-hole update and gravity refresh completed" + assert_output --partial "shutdown -r now" +} + +@test "ph-update -r does not restart Linux when the Pi-hole update fails" { + local probe_script="${TEST_TMPDIR}/ph-update" + ln -sf "${PROJECT_ROOT}/stow/tools/.local/bin/os-doctor" "${TEST_TMPDIR}/os-doctor" + sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" > "${probe_script}" + chmod +x "${probe_script}" + + # The update runs; the gravity refresh after it fails. + cat > "${BIN_SANDBOX}/pihole" <<'MOCK' +#!/usr/bin/env bash +printf 'pihole %s\n' "$*" +[[ "$1" != -f ]] +MOCK + printf '#!/usr/bin/env bash\n' > "${BIN_SANDBOX}/ph-padd" + printf '#!/usr/bin/env bash\nprintf "shutdown %%s\\n" "$*" >> "%s/shutdown.log"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/shutdown" + chmod +x "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" "${BIN_SANDBOX}/shutdown" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" PH_UPDATE_SKIP_OS_UPDATE=1 \ + DOTFILES_DIR="${PROJECT_ROOT}" "${probe_script}" -r + assert_failure + assert_output --partial "pihole -up" + assert_output --partial "Pi-hole update or gravity refresh failed" + refute_output --partial "Running shutdown -r now" + [ ! -e "${TEST_TMPDIR}/shutdown.log" ] +} + +@test "ph-update fails before os-doctor when pihole is unavailable" { + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" +MOCK + + cat > "${BIN_SANDBOX}/apt-get" <<'MOCK' +#!/usr/bin/env bash +printf 'apt-get %s\n' "$*" +MOCK + + chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/apt-get" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" + assert_failure + assert_output --partial "pihole command is unavailable" + [[ "${output}" != *"sudo apt-get update"* ]] +} + +@test "ph-update as root searches only system directories, not the caller's PATH" { + local probe_script="${TEST_TMPDIR}/ph-update" + + [[ ! -e /usr/local/bin/pihole && ! -e /usr/bin/pihole ]] || skip "pihole is installed on this host" + + # Take the root branch without being root; stay out of os-doctor and sudo. + sed -e 's/^if (( EUID == 0 )); then$/if true; then/' \ + -e 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" > "${probe_script}" + chmod +x "${probe_script}" + + local cmd + for cmd in pihole ph-padd shutdown gum; do + printf '#!/usr/bin/env bash\ntouch "%s/user-path-used"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/${cmd}" + chmod +x "${BIN_SANDBOX}/${cmd}" + done + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" PH_UPDATE_SKIP_OS_UPDATE=1 \ + DOTFILES_DIR="${PROJECT_ROOT}" "${probe_script}" + assert_failure + assert_output --partial "pihole command is unavailable" + [[ ! -e "${TEST_TMPDIR}/user-path-used" ]] +} + +@test "ph-test displays help" { + run "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" --help + assert_success + assert_output --partial "Usage:" + assert_output --partial "ph-test [dns-server-ip]" + assert_output --partial "Defaults to 127.0.0.1" +} + +@test "ph-test self-elevates through sudo and preserves dns server arg" { + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" +MOCK + chmod +x "${BIN_SANDBOX}/sudo" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" 192.0.2.53 + assert_success + refute_output --partial "PATH=" + assert_output --partial "sudo ${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test 192.0.2.53" +} + +@test "ph-test finds unbound commands through supplemental sbin paths" { + local unbound_sbin="${TEST_TMPDIR}/unbound-sbin" + mkdir -p "${unbound_sbin}" + + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +script_path="${1}" +shift + +tmp_script="$(mktemp)" +awk ' + /^# Self-elevate if not root/ { skip=1; next } + skip && /^DNS_SERVER=/ { skip=0 } + !skip { + if ($0 == "main \"$@\"") { + print "check_deps" + exit + } + print + } +' "${script_path}" > "${tmp_script}" + +chmod +x "${tmp_script}" +exec "${tmp_script}" "$@" +MOCK + + cat > "${BIN_SANDBOX}/dig" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + cat > "${BIN_SANDBOX}/ss" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + cat > "${unbound_sbin}/unbound-checkconf" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then + printf '/etc/unbound/unbound.conf\n' +fi +exit 0 +MOCK + + cat > "${unbound_sbin}/unbound-control" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + chmod +x \ + "${BIN_SANDBOX}/sudo" \ + "${BIN_SANDBOX}/dig" \ + "${BIN_SANDBOX}/ss" \ + "${unbound_sbin}/unbound-checkconf" \ + "${unbound_sbin}/unbound-control" + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" PH_TEST_SBIN_PATHS="${unbound_sbin}" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" + assert_success + [[ "${output}" != *"Missing dependency: unbound-control"* ]] + [[ "${output}" != *"Missing dependency: unbound-checkconf"* ]] +} + +@test "ph-test prefers Pi-hole include settings and falls back to unbound.conf" { + local primary_conf="${TEST_TMPDIR}/pi-hole.conf" + local main_conf="${TEST_TMPDIR}/unbound.conf" + + cat > "${primary_conf}" <<'CONF' +server: + interface: 127.0.0.1 +CONF + + cat > "${main_conf}" <<'CONF' +server: + interface: 0.0.0.0 + hide-version: yes +CONF + + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +script_path="${1}" +shift + +tmp_script="$(mktemp)" +awk ' + /^# Self-elevate if not root/ { skip=1; next } + skip && /^DNS_SERVER=/ { skip=0 } + !skip { + if ($0 == "main \"$@\"") { + print "printf '\''UNBOUND_CONF=%s\\n'\'' \"${UNBOUND_CONF}\"" + print "printf '\''interface=%s\\n'\'' \"$(get_unbound_setting interface)\"" + print "printf '\''hide-version=%s\\n'\'' \"$(get_unbound_setting hide-version)\"" + exit + } + print + } +' "${script_path}" > "${tmp_script}" + +chmod +x "${tmp_script}" +exec "${tmp_script}" "$@" +MOCK + + chmod +x "${BIN_SANDBOX}/sudo" + + run env \ + PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ + PH_TEST_UNBOUND_CONF="${primary_conf}" \ + PH_TEST_UNBOUND_MAIN_CONF="${main_conf}" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" + assert_success + assert_output --partial "UNBOUND_CONF=${primary_conf}" + assert_output --partial "interface=127.0.0.1" + assert_output --partial "hide-version=yes" +} + +@test "ph-test requests AD explicitly and prints a direct-fix tip when AD is hidden" { + local conf_file="${TEST_TMPDIR}/unbound.conf" + local dig_log="${TEST_TMPDIR}/dig.log" + + cat > "${conf_file}" <<'CONF' +server: + interface: 127.0.0.1 + port: 5335 +CONF + + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +script_path="${1}" +shift + +tmp_script="$(mktemp)" +awk ' + /^# Self-elevate if not root/ { skip=1; next } + skip && /^DNS_SERVER=/ { skip=0 } + /^main\(\) \{$/ { + print "main() {" + print " init_pihole_vars" + print " test_dnssec" + print " flush_section" + print " print_summary" + print "}" + in_main=1 + next + } + in_main && /^}$/ { + in_main=0 + next + } + !skip && !in_main { + print + } +' "${script_path}" > "${tmp_script}" + +chmod +x "${tmp_script}" +exec "${tmp_script}" "$@" +MOCK + + cat > "${BIN_SANDBOX}/dig" <<'MOCK' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${PH_TEST_DIG_LOG}" + +if [[ "$*" == *"dnssec-failed.org"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 +OUT + exit 0 +fi + +if [[ "$*" == *"cloudflare.com"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 +;; flags: qr rd ra; QUERY: 1, ANSWER: 1 +OUT + exit 0 +fi + +exit 0 +MOCK + + cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-x" && "${2:-}" == "pihole-FTL" ]]; then + exit 0 +fi +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/find" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + chmod +x \ + "${BIN_SANDBOX}/sudo" \ + "${BIN_SANDBOX}/dig" \ + "${BIN_SANDBOX}/pgrep" \ + "${BIN_SANDBOX}/find" + + run env \ + PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ + PH_TEST_DIG_LOG="${dig_log}" \ + PH_TEST_UNBOUND_CONF="${conf_file}" \ + PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" + assert_success + assert_output --partial "AD flag not exposed on direct query to 127.0.0.1:5335" + assert_output --partial "Re-test the direct resolver socket: dig @127.0.0.1 -p 5335 cloudflare.com +dnssec +adflag" + + run cat "${dig_log}" + assert_success + assert_output --partial "@127.0.0.1 -p 5335 cloudflare.com +dnssec +adflag" + refute_output --partial "google.com +dnssec" +} + +# run_with_timeout polls its command once a second; a fifth of a second per +# tick keeps a 5s budget at 1s of real time. +stub_ph_test_sleep() { + printf '#!/bin/sh\nexec /bin/sleep 0.2\n' > "${BIN_SANDBOX}/sleep" + chmod +x "${BIN_SANDBOX}/sleep" +} + +@test "ph-test still prints summary when stats collection fails" { + stub_ph_test_sleep + local conf_file="${TEST_TMPDIR}/unbound.conf" + + cat > "${conf_file}" <<'CONF' +server: + interface: 127.0.0.1 + hide-version: yes + hide-identity: yes + harden-glue: yes + harden-dnssec-stripped: yes +CONF + + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == */ph-test ]]; then + script_path="${1}" + shift + + tmp_script="$(mktemp)" + awk ' + /^# Self-elevate if not root/ { skip=1; next } + skip && /^DNS_SERVER=/ { skip=0 } + !skip { + print + } + ' "${script_path}" > "${tmp_script}" + + chmod +x "${tmp_script}" + exec "${tmp_script}" "$@" +fi + +exec "$@" +MOCK + + cat > "${BIN_SANDBOX}/unbound-checkconf" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then + printf '%s\n' "${PH_TEST_UNBOUND_MAIN_CONF}" + exit 0 +fi + +printf 'no errors\n' +MOCK + + cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' +#!/usr/bin/env bash +case "${1:-}" in + status) + printf 'version 1.0\n' + printf 'daemon is running\n' + ;; + stats_noreset) + exit 1 + ;; + *) + exit 0 + ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/ss" <<'MOCK' +#!/usr/bin/env bash +printf 'udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("unbound",pid=1,fd=3))\n' +MOCK + + cat > "${BIN_SANDBOX}/dig" <<'MOCK' +#!/usr/bin/env bash +if [[ "$*" == *"google.com +short"* ]]; then + printf '142.250.190.14\n' + exit 0 +fi + +if [[ "$*" == *"cloudflare.com +dnssec"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 +;; flags: qr rd ra; QUERY: 1, ANSWER: 1 +OUT + exit 0 +fi + +if [[ "$*" == *"dnssec-failed.org"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 +OUT + exit 0 +fi + +if [[ "$*" == *"doubleclick.net"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 1 +OUT + exit 0 +fi + +cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 +;; Query time: 12 msec +OUT +MOCK + + cat > "${BIN_SANDBOX}/ps" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + cat > "${BIN_SANDBOX}/id" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "unbound" ]]; then + exit 0 +fi +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/getent" <<'MOCK' +#!/usr/bin/env bash +if [[ "${2:-}" == "unbound" ]]; then + exit 0 +fi +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/systemctl" <<'MOCK' +#!/usr/bin/env bash +case "${1:-}" in + is-enabled) + exit 0 + ;; + is-active) + exit 1 + ;; + *) + exit 0 + ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/stat" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-c" && "${2:-}" == "%U:%G" ]]; then + printf 'root:root\n' +elif [[ "${1:-}" == "-c" && "${2:-}" == "%a" ]]; then + printf '755\n' +fi +MOCK + + cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/find" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + chmod +x \ + "${BIN_SANDBOX}/sudo" \ + "${BIN_SANDBOX}/unbound-checkconf" \ + "${BIN_SANDBOX}/unbound-control" \ + "${BIN_SANDBOX}/ss" \ + "${BIN_SANDBOX}/dig" \ + "${BIN_SANDBOX}/ps" \ + "${BIN_SANDBOX}/id" \ + "${BIN_SANDBOX}/getent" \ + "${BIN_SANDBOX}/systemctl" \ + "${BIN_SANDBOX}/stat" \ + "${BIN_SANDBOX}/pgrep" \ + "${BIN_SANDBOX}/find" + + run env \ + PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ + PH_TEST_UNBOUND_CONF="${conf_file}" \ + PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" + assert_failure + assert_output --partial "Summary" + assert_output --partial "Could not retrieve stats" + assert_output --partial "Found 1 failing check(s)." +} + +@test "ph-test times out hung stats collection and still prints summary" { + stub_ph_test_sleep + local conf_file="${TEST_TMPDIR}/unbound.conf" + + cat > "${conf_file}" <<'CONF' +server: + interface: 127.0.0.1 + hide-version: yes + hide-identity: yes + harden-glue: yes + harden-dnssec-stripped: yes +CONF + + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == */ph-test ]]; then + script_path="${1}" + shift + + tmp_script="$(mktemp)" + awk ' + /^# Self-elevate if not root/ { skip=1; next } + skip && /^DNS_SERVER=/ { skip=0 } + !skip { + print + } + ' "${script_path}" > "${tmp_script}" + + chmod +x "${tmp_script}" + exec "${tmp_script}" "$@" +fi + +exec "$@" +MOCK + + cat > "${BIN_SANDBOX}/unbound-checkconf" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then + printf '%s\n' "${PH_TEST_UNBOUND_MAIN_CONF}" + exit 0 +fi + +printf 'no errors\n' +MOCK + + cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' +#!/usr/bin/env bash +case "${1:-}" in + status) + printf 'version 1.0\n' + printf 'daemon is running\n' + ;; + stats_noreset) + exec /bin/sleep 5 + ;; + *) + exit 0 + ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/ss" <<'MOCK' +#!/usr/bin/env bash +printf 'udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("unbound",pid=1,fd=3))\n' +MOCK + + cat > "${BIN_SANDBOX}/dig" <<'MOCK' +#!/usr/bin/env bash +if [[ "$*" == *"google.com +short"* ]]; then + printf '142.250.190.14\n' + exit 0 +fi + +if [[ "$*" == *"cloudflare.com +dnssec"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 +;; flags: qr rd ra; QUERY: 1, ANSWER: 1 +OUT + exit 0 +fi + +if [[ "$*" == *"dnssec-failed.org"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 +OUT + exit 0 +fi + +if [[ "$*" == *"doubleclick.net"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 1 +OUT + exit 0 +fi + +cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 +;; Query time: 12 msec +OUT +MOCK + + cat > "${BIN_SANDBOX}/ps" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + cat > "${BIN_SANDBOX}/id" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "unbound" ]]; then + exit 0 +fi +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/getent" <<'MOCK' +#!/usr/bin/env bash +if [[ "${2:-}" == "unbound" ]]; then + exit 0 +fi +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/systemctl" <<'MOCK' +#!/usr/bin/env bash +case "${1:-}" in + is-enabled) + exit 0 + ;; + is-active) + exit 1 + ;; + *) + exit 0 + ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/stat" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-c" && "${2:-}" == "%U:%G" ]]; then + printf 'root:root\n' +elif [[ "${1:-}" == "-c" && "${2:-}" == "%a" ]]; then + printf '755\n' +fi +MOCK + + cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/find" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + chmod +x \ + "${BIN_SANDBOX}/sudo" \ + "${BIN_SANDBOX}/unbound-checkconf" \ + "${BIN_SANDBOX}/unbound-control" \ + "${BIN_SANDBOX}/ss" \ + "${BIN_SANDBOX}/dig" \ + "${BIN_SANDBOX}/ps" \ + "${BIN_SANDBOX}/id" \ + "${BIN_SANDBOX}/getent" \ + "${BIN_SANDBOX}/systemctl" \ + "${BIN_SANDBOX}/stat" \ + "${BIN_SANDBOX}/pgrep" \ + "${BIN_SANDBOX}/find" + + run env \ + PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ + PH_TEST_TIMEOUT_SECONDS=1 \ + PH_TEST_UNBOUND_CONF="${conf_file}" \ + PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" + assert_failure + assert_output --partial "Summary" + assert_output --partial "Could not retrieve stats (timed out after 1s)" + assert_output --partial "Found 1 failing check(s)." +} + +@test "ph-test still prints summary when stats output omits some keys" { + stub_ph_test_sleep + local conf_file="${TEST_TMPDIR}/unbound.conf" + + cat > "${conf_file}" <<'CONF' +server: + interface: 127.0.0.1 + hide-version: yes + hide-identity: yes + harden-glue: yes + harden-dnssec-stripped: yes +CONF + + cat > "${BIN_SANDBOX}/sudo" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == */ph-test ]]; then + script_path="${1}" + shift + + tmp_script="$(mktemp)" + awk ' + /^# Self-elevate if not root/ { skip=1; next } + skip && /^DNS_SERVER=/ { skip=0 } + !skip { + print + } + ' "${script_path}" > "${tmp_script}" + + chmod +x "${tmp_script}" + exec "${tmp_script}" "$@" +fi + +exec "$@" +MOCK + + cat > "${BIN_SANDBOX}/unbound-checkconf" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then + printf '%s\n' "${PH_TEST_UNBOUND_MAIN_CONF}" + exit 0 +fi + +printf 'no errors\n' +MOCK + + cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' +#!/usr/bin/env bash +case "${1:-}" in + status) + printf 'version 1.0\n' + printf 'daemon is running\n' + ;; + stats_noreset) + cat <<'OUT' +total.num.queries=42 +total.num.cachehits=10 +OUT + ;; + *) + exit 0 + ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/ss" <<'MOCK' +#!/usr/bin/env bash +printf 'udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("unbound",pid=1,fd=3))\n' +MOCK + + cat > "${BIN_SANDBOX}/dig" <<'MOCK' +#!/usr/bin/env bash +if [[ "$*" == *"google.com +short"* ]]; then + printf '142.250.190.14\n' + exit 0 +fi + +if [[ "$*" == *"cloudflare.com +dnssec"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 +;; flags: qr rd ra; QUERY: 1, ANSWER: 1 +OUT + exit 0 +fi + +if [[ "$*" == *"dnssec-failed.org"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 +OUT + exit 0 +fi + +if [[ "$*" == *"doubleclick.net"* ]]; then + cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 1 +OUT + exit 0 +fi + +cat <<'OUT' +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 +;; Query time: 12 msec +OUT +MOCK + + cat > "${BIN_SANDBOX}/ps" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + cat > "${BIN_SANDBOX}/id" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "unbound" ]]; then + exit 0 +fi +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/getent" <<'MOCK' +#!/usr/bin/env bash +if [[ "${2:-}" == "unbound" ]]; then + exit 0 +fi +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/systemctl" <<'MOCK' +#!/usr/bin/env bash +case "${1:-}" in + is-enabled) + exit 0 + ;; + is-active) + exit 1 + ;; + *) + exit 0 + ;; +esac +MOCK + + cat > "${BIN_SANDBOX}/stat" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-c" && "${2:-}" == "%U:%G" ]]; then + printf 'root:root\n' +elif [[ "${1:-}" == "-c" && "${2:-}" == "%a" ]]; then + printf '755\n' +fi +MOCK + + cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK + + cat > "${BIN_SANDBOX}/find" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK + + chmod +x \ + "${BIN_SANDBOX}/sudo" \ + "${BIN_SANDBOX}/unbound-checkconf" \ + "${BIN_SANDBOX}/unbound-control" \ + "${BIN_SANDBOX}/ss" \ + "${BIN_SANDBOX}/dig" \ + "${BIN_SANDBOX}/ps" \ + "${BIN_SANDBOX}/id" \ + "${BIN_SANDBOX}/getent" \ + "${BIN_SANDBOX}/systemctl" \ + "${BIN_SANDBOX}/stat" \ + "${BIN_SANDBOX}/pgrep" \ + "${BIN_SANDBOX}/find" + + run env \ + PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ + PH_TEST_UNBOUND_CONF="${conf_file}" \ + PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" + assert_success + assert_output --partial "Summary" + assert_output --partial "total.num.queries: 42" + assert_output --partial "Passed:" +} + +@test "ph-test as root searches only system directories, not the caller's PATH" { + local probe_script="${TEST_TMPDIR}/ph-test" + local dir + + for dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do + [[ ! -e "${dir}/unbound-control" ]] || skip "unbound is installed on this host" + done + + # Take the root branch without being root. + sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ + "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" > "${probe_script}" + chmod +x "${probe_script}" + + local cmd + for cmd in dig unbound-control unbound-checkconf ss gum sudo; do + printf '#!/usr/bin/env bash\ntouch "%s/user-path-used"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/${cmd}" + chmod +x "${BIN_SANDBOX}/${cmd}" + done + + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" + assert_failure + assert_output --partial "Missing dependency: unbound-control" + [[ ! -e "${TEST_TMPDIR}/user-path-used" ]] +} diff --git a/tests/bin_scripts.bats b/tests/bin_scripts.bats index 397a497..9832456 100644 --- a/tests/bin_scripts.bats +++ b/tests/bin_scripts.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# Tests for the stow/*/.local/bin scripts — isolated smoke and behavior coverage. +# Tests for the stow/*/.local/bin scripts other than the Pi-hole ones, firetv, ai-doctor, and ph: isolated smoke and behavior coverage. load test_helper @@ -24,457 +24,6 @@ teardown() { teardown_tmpdir } -@test "ph-sec-audit defaults to a plan without applying changes" { - run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" - assert_success - assert_output --partial "Without --apply" - refute_output --partial "Backups:" - - run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --upgrade-packages - assert_success - assert_output --partial "Without --apply" - refute_output --partial "Backups:" -} - -@test "ph-sec-audit requires a target and rejects malformed arguments" { - run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --apply - assert_failure - assert_output --partial "requires an explicit valid --user and --host" - - run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --apply --user --host - assert_failure - assert_output --partial "Missing value" - - run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" --unknown - assert_failure - assert_output --partial "Unknown argument" -} - -@test "ph-sec-audit refuses a different host before making changes" { - run bash "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" \ - --apply --user operator --host "$(hostname)-wrong-target" - assert_failure - assert_output --partial "Host mismatch; no changes made" - refute_output --partial "Backups:" -} - -run_remediation_web_probe() { - local probe_script="${TEST_TMPDIR}/web-probe.sh" - - cat > "${probe_script}" <<'PROBE' -#!/usr/bin/env bash -set -euo pipefail -old_ports='original-ports' -backup_dir="${TEST_TMPDIR}" -pihole-FTL() { - printf '%s\n' "${3}" >> "${TEST_TMPDIR}/port-changes" -} -systemctl() { - if [[ ! -f "${TEST_TMPDIR}/restart-attempted" ]]; then - touch "${TEST_TMPDIR}/restart-attempted" - case "${PROBE_FAILURE}" in - restart) return 7 ;; - interrupt) kill -TERM "$$" ;; - esac - fi -} -curl() { return 1; } -sleep() { :; } -PROBE - awk ' - /^restore_web_ports\(\) \{/ { copy=1 } - copy { print } - /^trap - ERR HUP INT TERM$/ { exit } - ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" >> "${probe_script}" - - run env PROBE_FAILURE="${1}" bash "${probe_script}" -} - -@test "ph-sec-audit restores web ports when verification fails" { - run_remediation_web_probe verification - assert_failure - assert_output --partial "restoring original port configuration" - run tail -n 1 "${TEST_TMPDIR}/port-changes" - assert_success - assert_output "original-ports" -} - -@test "ph-sec-audit restores web ports and preserves restart failure status" { - run_remediation_web_probe restart - assert_equal "${status}" 7 - assert_output --partial "restoring original port configuration" - run tail -n 1 "${TEST_TMPDIR}/port-changes" - assert_success - assert_output "original-ports" -} - -@test "ph-sec-audit restores web ports when interrupted" { - run_remediation_web_probe interrupt - assert_equal "${status}" 143 - assert_output --partial "restoring original port configuration" - run tail -n 1 "${TEST_TMPDIR}/port-changes" - assert_success - assert_output "original-ports" -} - -# Runs ph-sec-audit's report_web_password with pihole-FTL answering $1. -run_web_password_probe() { - local probe_script="${TEST_TMPDIR}/password-probe.sh" - - { - printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \ - 'pihole-FTL() { printf "%s\n" "${PROBE_HASH}"; }' - awk ' - /^report_web_password\(\) \{/ { copy=1 } - copy { print } - copy && /^}$/ { exit } - ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-sec-audit" - printf '%s\n' 'report_web_password' - } > "${probe_script}" - - run env PROBE_HASH="${1}" bash "${probe_script}" -} - -@test "ph-sec-audit reports whether a web password is set" { - run_web_password_probe '$BALLOON-SHA256$v=1$fakehash' - assert_success - assert_output "A web password is set." - - run_web_password_probe '""' - assert_success - assert_output --partial "No web password is set" - assert_output --partial "sudo pihole setpassword" - - run_web_password_probe '' - assert_success - assert_output --partial "No web password is set" -} - -run_padd_api_probe() { - local source_script="${1}" - local probe_script="${TEST_TMPDIR}/$(basename "${source_script}")" - - awk ' - /^main\(\)\{$/ { - print "main(){" - print " xOffset=0" - print " TestAPIAvailability" - print "}" - skip=1 - next - } - skip && /^}$/ { - skip=0 - next - } - skip { - next - } - { print } - ' "${source_script}" > "${probe_script}" - - chmod +x "${probe_script}" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" -} - -run_padd_dns_unbound_probe() { - local source_script="${1}" - local probe_script="${TEST_TMPDIR}/$(basename "${source_script}")" - - awk ' - /^main\(\)\{$/ { - print "main(){" - print " connection_down_flag=true" - print " GetNetworkInformation" - print " printf '\''status=%s\\n'\'' \"${unbound_status}\"" - print " printf '\''listener=%s\\n'\'' \"${unbound_listener}\"" - print " printf '\''dnssec=%s\\n'\'' \"${unbound_dnssec_status}\"" - print " printf '\''cache=%s\\n'\'' \"${unbound_cache_status}\"" - print "}" - skip=1 - next - } - skip && /^}$/ { - skip=0 - next - } - skip { - next - } - { print } - ' "${source_script}" > "${probe_script}" - - chmod +x "${probe_script}" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" -} - -@test "ph-padd displays help" { - run sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-padd" --help - assert_success - assert_output --partial "PADD displays stats about your Pi-hole" - assert_output --partial "--api" - assert_output --partial "--runonce" -} - -@test "ph-padd startup probe fails fast when no API URLs are discovered" { - cat > "${BIN_SANDBOX}/dig" < "${TEST_TMPDIR}/ph-padd-dig-args" -exit 0 -MOCK - chmod +x "${BIN_SANDBOX}/dig" - - run_padd_api_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-padd" - assert_failure - assert_output --partial "API not available at: localhost" - - run cat "${TEST_TMPDIR}/ph-padd-dig-args" - assert_success - assert_output --partial "+time=2" - assert_output --partial "+tries=1" -} - -@test "ph-padd surfaces unbound listener and cache details" { - local main_conf="${TEST_TMPDIR}/unbound.conf" - local include_conf="${TEST_TMPDIR}/pi-hole.conf" - - cat > "${main_conf}" <<'CONF' -remote-control: - control-enable: yes -CONF - - cat > "${include_conf}" <<'CONF' -server: - interface: 127.0.0.1 - port: 5335 - harden-dnssec-stripped: yes -CONF - - cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' -#!/usr/bin/env bash -case "${1:-}" in - status) - printf 'unbound is running as pid 1234\n' - ;; - stats_noreset) - printf 'total.num.queries=200\n' - printf 'total.num.cachehits=150\n' - ;; -esac -MOCK - - chmod +x "${BIN_SANDBOX}/unbound-control" - - export PH_PADD_UNBOUND_MAIN_CONF="${main_conf}" - export PH_PADD_UNBOUND_CONF="${include_conf}" - - run_padd_dns_unbound_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-padd" - - assert_success - assert_output --partial "status=Running" - assert_output --partial "listener=127.0.0.1:5335" - assert_output --partial "dnssec=Hardened" - assert_output --partial "cache=75% hit" - - unset PH_PADD_UNBOUND_MAIN_CONF - unset PH_PADD_UNBOUND_CONF -} - -@test "ph-padd does not splice unbound stats into awk code" { - local marker="${TEST_TMPDIR}/awk-ran" - - cat > "${BIN_SANDBOX}/unbound-control" < "${probe_script}" - chmod +x "${probe_script}" - - rm -f "${TEST_TMPDIR}/curl-args" "${TEST_TMPDIR}/curl-stdin" - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" "$@" -} - -# curl mock: logs argv and stdin separately and prints curl-body (plus the -# >>status suffix when called with -w). dig mock prints dig-reply. -write_padd_network_mocks() { - ln -sf "$(command -v jq)" "${BIN_SANDBOX}/jq" - - cat > "${BIN_SANDBOX}/curl" <> "${TEST_TMPDIR}/curl-args" -for arg in "\$@"; do - case "\${arg}" in - -|@-) cat >> "${TEST_TMPDIR}/curl-stdin"; printf '\n' >> "${TEST_TMPDIR}/curl-stdin" ;; - esac -done -cat "${TEST_TMPDIR}/curl-body" 2>/dev/null -for arg in "\$@"; do - [[ "\${arg}" == "-w" ]] && printf '>>200' -done -exit 0 -MOCK - - cat > "${BIN_SANDBOX}/dig" </dev/null -MOCK - chmod +x "${BIN_SANDBOX}/curl" "${BIN_SANDBOX}/dig" -} - -@test "ph-padd scripts never evaluate API numbers in shell arithmetic" { - local marker="${TEST_TMPDIR}/pwned" script - write_padd_network_mocks - printf '{"system":{"uptime":"a[$(touch %s)]"}}' "${marker}" > "${TEST_TMPDIR}/curl-body" - - for script in ph-padd; do - PADD_PROBE_MAIN='API_URL=https://127.0.0.1/api/; GetPADDData; convertUptime "$(GetPADDValue system.uptime)"; echo; convertUptime 90061' \ - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" - assert_success - assert_output --partial "0 days, 00 hours, 00 minutes" - assert_output --partial "1 days, 01 hours, 01 minutes" - assert [ ! -e "${marker}" ] - done -} - -@test "ph-padd scripts strip control characters from API strings" { - local script - write_padd_network_mocks - printf '%s' '{"node_name":"evil\u001b]0;t\u0007\nsystem.uptime=5"}' > "${TEST_TMPDIR}/curl-body" - - for script in ph-padd; do - PADD_PROBE_MAIN='API_URL=https://127.0.0.1/api/; GetPADDData; printf "host=%s|uptime=%s\n" "$(GetPADDValue node_name)" "$(GetPADDValue system.uptime)"' \ - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" - assert_success - assert_output "host=evil]0;tsystem.uptime=5|uptime=" - done -} - -@test "ph-padd scripts verify TLS for remote servers and pin discovered URLs to --server" { - local script - write_padd_network_mocks - printf '%s\n' '"https://attacker.example/api/" "http://attacker.example:80/api/"' > "${TEST_TMPDIR}/dig-reply" - : > "${TEST_TMPDIR}/ca.pem" - PADD_PROBE_MAIN='TestAPIAvailability; printf "api=%s\n" "${API_URL}"' - - for script in ph-padd; do - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 - assert_success - assert_output --partial "api=https://192.0.2.10/api/" - run cat "${TEST_TMPDIR}/curl-args" - assert_output --partial "https://192.0.2.10/api/auth" - refute_output --partial "--insecure" - refute_output --regexp '(^| )-[a-zA-Z]*k' - - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 --cacert "${TEST_TMPDIR}/ca.pem" - assert_success - run cat "${TEST_TMPDIR}/curl-args" - assert_output --partial "--cacert ${TEST_TMPDIR}/ca.pem" - refute_output --partial "--insecure" - - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 --insecure - assert_success - run cat "${TEST_TMPDIR}/curl-args" - assert_output --partial "--insecure" - - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 127.0.0.1 - assert_success - assert_output --partial "api=https://127.0.0.1/api/" - run cat "${TEST_TMPDIR}/curl-args" - assert_output --partial "--insecure" - done -} - -@test "ph-padd scripts refuse plain http API URLs for remote hosts" { - local script - write_padd_network_mocks - printf '%s\n' '"http://attacker.example:80/api/"' > "${TEST_TMPDIR}/dig-reply" - PADD_PROBE_MAIN='TestAPIAvailability; printf "api=%s\n" "${API_URL}"' - - for script in ph-padd; do - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --server 192.0.2.10 - assert_failure - assert_output --partial "Refusing plain http:// API URL http://192.0.2.10:80/api/" - assert [ ! -e "${TEST_TMPDIR}/curl-args" ] - - run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --api http://192.0.2.10/api/ - assert_failure - assert_output --partial "Refusing API URL http://192.0.2.10/api/" - assert [ ! -e "${TEST_TMPDIR}/curl-args" ] - done -} - -@test "ph-padd scripts keep the password and session ID out of curl argv" { - local script - write_padd_network_mocks - printf '%s' '{"session":{"valid":true,"sid":"c2Vzc2lvbg=="}}' > "${TEST_TMPDIR}/curl-body" - PADD_PROBE_MAIN='API_URL=https://192.0.2.10/api/; Authenticate; printf "valid=%s\n" "${validSession}"; GetFTLData info/ftl >/dev/null' - - for script in ph-padd; do - PADD_PASSWORD='hunter2"\x' run_padd_main_probe "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" - assert_success - assert_output --partial "valid=true" - - run cat "${TEST_TMPDIR}/curl-args" - refute_output --partial "hunter2" - refute_output --partial "c2Vzc2lvbg" - - run cat "${TEST_TMPDIR}/curl-stdin" - assert_output --partial '{"password":"hunter2\"\\x","totp":null}' - assert_output --partial 'header = "sid: c2Vzc2lvbg=="' - done -} - -@test "ph-padd scripts reject --secret and skip self-update" { - local script - - for script in ph-padd; do - run sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --secret hunter2 - assert_failure - assert_output --partial "PADD_PASSWORD" - - # -u never downloads: the file comes from the repo - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --update - assert_success - assert_output "ph-padd is managed by ~/.dotfiles; update it there." - - run sh "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/${script}" --help - assert_output --partial "--cacert" - assert_output --partial "--insecure" - refute_output --partial "--secret" - done -} - @test "os-doctor displays help" { run "${PROJECT_ROOT}/stow/tools/.local/bin/os-doctor" --help assert_success @@ -869,1567 +418,35 @@ MOCK printf 'KEY\n' > "${dir}/a.test.key" printf 'CHAIN\n' > "${dir}/a.test.fullchain" - run bash -c "cd '${TEST_TMPDIR}' && $(cat "${ACME_HOME}/reloadcmd")" - assert_success - run cat "${dir}/a.test.pem" - assert_output "$(printf 'KEY\nCHAIN')" - run ls -l "${dir}/a.test.pem" - assert_output --regexp '^-rw-------' - [ ! -e "${TEST_TMPDIR}/PWNED" ] && [ ! -e "${TEST_TMPDIR}/PWNED2" ] -} - -@test "cf-cert --pihole as root points Pi-hole at the pem and restarts it on renewal" { - setup_cf_cert_sandbox - sed 's/(( EUID == 0 )) || die "--pihole needs root/true || die "--pihole needs root/' "${CF_CERT}" > "${TEST_TMPDIR}/cf-cert" - chmod +x "${TEST_TMPDIR}/cf-cert" - - # Without pihole-FTL it stops before acme.sh runs. - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${TEST_TMPDIR}/cf-cert" --pihole a.test - assert_failure 1 - assert_output --partial "pihole-FTL not found" - [ ! -e "${ACME_HOME}/calls.log" ] - - printf '#!/usr/bin/env bash\nprintf "pihole-FTL %%s\\n" "$*" >> "%s/ftl.log"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/pihole-FTL" - chmod +x "${BIN_SANDBOX}/pihole-FTL" - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${TEST_TMPDIR}/cf-cert" --pihole a.test - assert_success - run cat "${TEST_TMPDIR}/ftl.log" - assert_output "pihole-FTL --config webserver.domain a.test -pihole-FTL --config webserver.tls.cert /etc/pihole/a.test.pem" - run grep -- "--install-cert" "${ACME_HOME}/calls.log" - assert_output "--home ${ACME_HOME} --install-cert -d a.test --ecc --key-file /etc/pihole/a.test.key --fullchain-file /etc/pihole/a.test.fullchain --reloadcmd cat /etc/pihole/a.test.key /etc/pihole/a.test.fullchain > /etc/pihole/a.test.pem && chmod 600 /etc/pihole/a.test.pem /etc/pihole/a.test.key && chown pihole:pihole /etc/pihole/a.test.pem /etc/pihole/a.test.key && systemctl restart pihole-FTL" -} - -@test "ph-update displays help" { - run "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" --help - assert_success - assert_output --partial "Usage:" - assert_output --partial "ph-update" - assert_output --partial "ph-update -r" - assert_output --partial "Run os-doctor, refresh Pi-hole, and optionally restart Linux" -} - -@test "ph-update runs os-doctor before self-elevating through sudo" { - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -printf 'sudo %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/apt-get" <<'MOCK' -#!/usr/bin/env bash -printf 'apt-get %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/pihole" <<'MOCK' -#!/usr/bin/env bash -printf 'pihole %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/ph-padd" <<'MOCK' -#!/usr/bin/env bash -printf 'ph-padd %s\n' "$*" -MOCK - - chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/apt-get" "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" - assert_success - assert_output --partial "sudo apt-get update" - refute_output --partial "PATH=" - assert_output --partial "sudo PH_UPDATE_SKIP_OS_UPDATE=1 ${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" -} - -@test "ph-update preserves -r through self-elevating sudo" { - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -printf 'sudo %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/apt-get" <<'MOCK' -#!/usr/bin/env bash -printf 'apt-get %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/pihole" <<'MOCK' -#!/usr/bin/env bash -printf 'pihole %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/ph-padd" <<'MOCK' -#!/usr/bin/env bash -printf 'ph-padd %s\n' "$*" -MOCK - - chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/apt-get" "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" -r - assert_success - assert_output --partial "PH_UPDATE_SKIP_OS_UPDATE=1" - assert_output --partial "ph-update -r" -} - -@test "ph-update rejects -r on non-linux hosts" { - run env OSTYPE="darwin23" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" -r - assert_failure - assert_output --partial "-r/--restart is only supported on Linux" -} - -@test "ph-update restarts Linux after a successful run when -r is set" { - local probe_script="${TEST_TMPDIR}/ph-update" - ln -sf "${PROJECT_ROOT}/stow/tools/.local/bin/os-doctor" "${TEST_TMPDIR}/os-doctor" - - sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" > "${probe_script}" - chmod +x "${probe_script}" - - cat > "${BIN_SANDBOX}/pihole" <<'MOCK' -#!/usr/bin/env bash -printf 'pihole %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/ph-padd" <<'MOCK' -#!/usr/bin/env bash -printf 'ph-padd %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/shutdown" <<'MOCK' -#!/usr/bin/env bash -printf 'shutdown %s\n' "$*" -MOCK - - chmod +x "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" "${BIN_SANDBOX}/shutdown" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" PH_UPDATE_SKIP_OS_UPDATE=1 \ - DOTFILES_DIR="${PROJECT_ROOT}" "${probe_script}" -r - assert_success - assert_output --partial "Pi-hole update and gravity refresh completed" - assert_output --partial "shutdown -r now" -} - -@test "ph-update -r does not restart Linux when the Pi-hole update fails" { - local probe_script="${TEST_TMPDIR}/ph-update" - ln -sf "${PROJECT_ROOT}/stow/tools/.local/bin/os-doctor" "${TEST_TMPDIR}/os-doctor" - sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" > "${probe_script}" - chmod +x "${probe_script}" - - # The update runs; the gravity refresh after it fails. - cat > "${BIN_SANDBOX}/pihole" <<'MOCK' -#!/usr/bin/env bash -printf 'pihole %s\n' "$*" -[[ "$1" != -f ]] -MOCK - printf '#!/usr/bin/env bash\n' > "${BIN_SANDBOX}/ph-padd" - printf '#!/usr/bin/env bash\nprintf "shutdown %%s\\n" "$*" >> "%s/shutdown.log"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/shutdown" - chmod +x "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/ph-padd" "${BIN_SANDBOX}/shutdown" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" PH_UPDATE_SKIP_OS_UPDATE=1 \ - DOTFILES_DIR="${PROJECT_ROOT}" "${probe_script}" -r - assert_failure - assert_output --partial "pihole -up" - assert_output --partial "Pi-hole update or gravity refresh failed" - refute_output --partial "Running shutdown -r now" - [ ! -e "${TEST_TMPDIR}/shutdown.log" ] -} - -@test "ph-update fails before os-doctor when pihole is unavailable" { - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -printf 'sudo %s\n' "$*" -MOCK - - cat > "${BIN_SANDBOX}/apt-get" <<'MOCK' -#!/usr/bin/env bash -printf 'apt-get %s\n' "$*" -MOCK - - chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/apt-get" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" - assert_failure - assert_output --partial "pihole command is unavailable" - [[ "${output}" != *"sudo apt-get update"* ]] -} - -@test "ph-update as root searches only system directories, not the caller's PATH" { - local probe_script="${TEST_TMPDIR}/ph-update" - - [[ ! -e /usr/local/bin/pihole && ! -e /usr/bin/pihole ]] || skip "pihole is installed on this host" - - # Take the root branch without being root; stay out of os-doctor and sudo. - sed -e 's/^if (( EUID == 0 )); then$/if true; then/' \ - -e 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-update" > "${probe_script}" - chmod +x "${probe_script}" - - local cmd - for cmd in pihole ph-padd shutdown gum; do - printf '#!/usr/bin/env bash\ntouch "%s/user-path-used"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/${cmd}" - chmod +x "${BIN_SANDBOX}/${cmd}" - done - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" PH_UPDATE_SKIP_OS_UPDATE=1 \ - DOTFILES_DIR="${PROJECT_ROOT}" "${probe_script}" - assert_failure - assert_output --partial "pihole command is unavailable" - [[ ! -e "${TEST_TMPDIR}/user-path-used" ]] -} - -@test "ph-test displays help" { - run "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" --help - assert_success - assert_output --partial "Usage:" - assert_output --partial "ph-test [dns-server-ip]" - assert_output --partial "Defaults to 127.0.0.1" -} - -@test "ph-test self-elevates through sudo and preserves dns server arg" { - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -printf 'sudo %s\n' "$*" -MOCK - chmod +x "${BIN_SANDBOX}/sudo" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" 192.0.2.53 - assert_success - refute_output --partial "PATH=" - assert_output --partial "sudo ${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test 192.0.2.53" -} - -@test "ph-test finds unbound commands through supplemental sbin paths" { - local unbound_sbin="${TEST_TMPDIR}/unbound-sbin" - mkdir -p "${unbound_sbin}" - - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail - -script_path="${1}" -shift - -tmp_script="$(mktemp)" -awk ' - /^# Self-elevate if not root/ { skip=1; next } - skip && /^DNS_SERVER=/ { skip=0 } - !skip { - if ($0 == "main \"$@\"") { - print "check_deps" - exit - } - print - } -' "${script_path}" > "${tmp_script}" - -chmod +x "${tmp_script}" -exec "${tmp_script}" "$@" -MOCK - - cat > "${BIN_SANDBOX}/dig" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - cat > "${BIN_SANDBOX}/ss" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - cat > "${unbound_sbin}/unbound-checkconf" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then - printf '/etc/unbound/unbound.conf\n' -fi -exit 0 -MOCK - - cat > "${unbound_sbin}/unbound-control" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - chmod +x \ - "${BIN_SANDBOX}/sudo" \ - "${BIN_SANDBOX}/dig" \ - "${BIN_SANDBOX}/ss" \ - "${unbound_sbin}/unbound-checkconf" \ - "${unbound_sbin}/unbound-control" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" PH_TEST_SBIN_PATHS="${unbound_sbin}" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" - assert_success - [[ "${output}" != *"Missing dependency: unbound-control"* ]] - [[ "${output}" != *"Missing dependency: unbound-checkconf"* ]] -} - -@test "ph-test prefers Pi-hole include settings and falls back to unbound.conf" { - local primary_conf="${TEST_TMPDIR}/pi-hole.conf" - local main_conf="${TEST_TMPDIR}/unbound.conf" - - cat > "${primary_conf}" <<'CONF' -server: - interface: 127.0.0.1 -CONF - - cat > "${main_conf}" <<'CONF' -server: - interface: 0.0.0.0 - hide-version: yes -CONF - - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail - -script_path="${1}" -shift - -tmp_script="$(mktemp)" -awk ' - /^# Self-elevate if not root/ { skip=1; next } - skip && /^DNS_SERVER=/ { skip=0 } - !skip { - if ($0 == "main \"$@\"") { - print "printf '\''UNBOUND_CONF=%s\\n'\'' \"${UNBOUND_CONF}\"" - print "printf '\''interface=%s\\n'\'' \"$(get_unbound_setting interface)\"" - print "printf '\''hide-version=%s\\n'\'' \"$(get_unbound_setting hide-version)\"" - exit - } - print - } -' "${script_path}" > "${tmp_script}" - -chmod +x "${tmp_script}" -exec "${tmp_script}" "$@" -MOCK - - chmod +x "${BIN_SANDBOX}/sudo" - - run env \ - PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ - PH_TEST_UNBOUND_CONF="${primary_conf}" \ - PH_TEST_UNBOUND_MAIN_CONF="${main_conf}" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" - assert_success - assert_output --partial "UNBOUND_CONF=${primary_conf}" - assert_output --partial "interface=127.0.0.1" - assert_output --partial "hide-version=yes" -} - -@test "ph-test requests AD explicitly and prints a direct-fix tip when AD is hidden" { - local conf_file="${TEST_TMPDIR}/unbound.conf" - local dig_log="${TEST_TMPDIR}/dig.log" - - cat > "${conf_file}" <<'CONF' -server: - interface: 127.0.0.1 - port: 5335 -CONF - - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail - -script_path="${1}" -shift - -tmp_script="$(mktemp)" -awk ' - /^# Self-elevate if not root/ { skip=1; next } - skip && /^DNS_SERVER=/ { skip=0 } - /^main\(\) \{$/ { - print "main() {" - print " init_pihole_vars" - print " test_dnssec" - print " flush_section" - print " print_summary" - print "}" - in_main=1 - next - } - in_main && /^}$/ { - in_main=0 - next - } - !skip && !in_main { - print - } -' "${script_path}" > "${tmp_script}" - -chmod +x "${tmp_script}" -exec "${tmp_script}" "$@" -MOCK - - cat > "${BIN_SANDBOX}/dig" <<'MOCK' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "${PH_TEST_DIG_LOG}" - -if [[ "$*" == *"dnssec-failed.org"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 -OUT - exit 0 -fi - -if [[ "$*" == *"cloudflare.com"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 -;; flags: qr rd ra; QUERY: 1, ANSWER: 1 -OUT - exit 0 -fi - -exit 0 -MOCK - - cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-x" && "${2:-}" == "pihole-FTL" ]]; then - exit 0 -fi -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/find" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - chmod +x \ - "${BIN_SANDBOX}/sudo" \ - "${BIN_SANDBOX}/dig" \ - "${BIN_SANDBOX}/pgrep" \ - "${BIN_SANDBOX}/find" - - run env \ - PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ - PH_TEST_DIG_LOG="${dig_log}" \ - PH_TEST_UNBOUND_CONF="${conf_file}" \ - PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" - assert_success - assert_output --partial "AD flag not exposed on direct query to 127.0.0.1:5335" - assert_output --partial "Re-test the direct resolver socket: dig @127.0.0.1 -p 5335 cloudflare.com +dnssec +adflag" - - run cat "${dig_log}" - assert_success - assert_output --partial "@127.0.0.1 -p 5335 cloudflare.com +dnssec +adflag" - refute_output --partial "google.com +dnssec" -} - -@test "ph-test still prints summary when stats collection fails" { - local conf_file="${TEST_TMPDIR}/unbound.conf" - - cat > "${conf_file}" <<'CONF' -server: - interface: 127.0.0.1 - hide-version: yes - hide-identity: yes - harden-glue: yes - harden-dnssec-stripped: yes -CONF - - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail - -if [[ "${1:-}" == */ph-test ]]; then - script_path="${1}" - shift - - tmp_script="$(mktemp)" - awk ' - /^# Self-elevate if not root/ { skip=1; next } - skip && /^DNS_SERVER=/ { skip=0 } - !skip { - print - } - ' "${script_path}" > "${tmp_script}" - - chmod +x "${tmp_script}" - exec "${tmp_script}" "$@" -fi - -exec "$@" -MOCK - - cat > "${BIN_SANDBOX}/unbound-checkconf" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then - printf '%s\n' "${PH_TEST_UNBOUND_MAIN_CONF}" - exit 0 -fi - -printf 'no errors\n' -MOCK - - cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' -#!/usr/bin/env bash -case "${1:-}" in - status) - printf 'version 1.0\n' - printf 'daemon is running\n' - ;; - stats_noreset) - exit 1 - ;; - *) - exit 0 - ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/ss" <<'MOCK' -#!/usr/bin/env bash -printf 'udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("unbound",pid=1,fd=3))\n' -MOCK - - cat > "${BIN_SANDBOX}/dig" <<'MOCK' -#!/usr/bin/env bash -if [[ "$*" == *"google.com +short"* ]]; then - printf '142.250.190.14\n' - exit 0 -fi - -if [[ "$*" == *"cloudflare.com +dnssec"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 -;; flags: qr rd ra; QUERY: 1, ANSWER: 1 -OUT - exit 0 -fi - -if [[ "$*" == *"dnssec-failed.org"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 -OUT - exit 0 -fi - -if [[ "$*" == *"doubleclick.net"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 1 -OUT - exit 0 -fi - -cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 -;; Query time: 12 msec -OUT -MOCK - - cat > "${BIN_SANDBOX}/ps" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - cat > "${BIN_SANDBOX}/id" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "unbound" ]]; then - exit 0 -fi -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/getent" <<'MOCK' -#!/usr/bin/env bash -if [[ "${2:-}" == "unbound" ]]; then - exit 0 -fi -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/systemctl" <<'MOCK' -#!/usr/bin/env bash -case "${1:-}" in - is-enabled) - exit 0 - ;; - is-active) - exit 1 - ;; - *) - exit 0 - ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/stat" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-c" && "${2:-}" == "%U:%G" ]]; then - printf 'root:root\n' -elif [[ "${1:-}" == "-c" && "${2:-}" == "%a" ]]; then - printf '755\n' -fi -MOCK - - cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' -#!/usr/bin/env bash -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/find" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - chmod +x \ - "${BIN_SANDBOX}/sudo" \ - "${BIN_SANDBOX}/unbound-checkconf" \ - "${BIN_SANDBOX}/unbound-control" \ - "${BIN_SANDBOX}/ss" \ - "${BIN_SANDBOX}/dig" \ - "${BIN_SANDBOX}/ps" \ - "${BIN_SANDBOX}/id" \ - "${BIN_SANDBOX}/getent" \ - "${BIN_SANDBOX}/systemctl" \ - "${BIN_SANDBOX}/stat" \ - "${BIN_SANDBOX}/pgrep" \ - "${BIN_SANDBOX}/find" - - run env \ - PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ - PH_TEST_UNBOUND_CONF="${conf_file}" \ - PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" - assert_failure - assert_output --partial "Summary" - assert_output --partial "Could not retrieve stats" - assert_output --partial "Found 1 failing check(s)." -} - -@test "ph-test times out hung stats collection and still prints summary" { - local conf_file="${TEST_TMPDIR}/unbound.conf" - - cat > "${conf_file}" <<'CONF' -server: - interface: 127.0.0.1 - hide-version: yes - hide-identity: yes - harden-glue: yes - harden-dnssec-stripped: yes -CONF - - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail - -if [[ "${1:-}" == */ph-test ]]; then - script_path="${1}" - shift - - tmp_script="$(mktemp)" - awk ' - /^# Self-elevate if not root/ { skip=1; next } - skip && /^DNS_SERVER=/ { skip=0 } - !skip { - print - } - ' "${script_path}" > "${tmp_script}" - - chmod +x "${tmp_script}" - exec "${tmp_script}" "$@" -fi - -exec "$@" -MOCK - - cat > "${BIN_SANDBOX}/unbound-checkconf" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then - printf '%s\n' "${PH_TEST_UNBOUND_MAIN_CONF}" - exit 0 -fi - -printf 'no errors\n' -MOCK - - cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' -#!/usr/bin/env bash -case "${1:-}" in - status) - printf 'version 1.0\n' - printf 'daemon is running\n' - ;; - stats_noreset) - sleep 5 - ;; - *) - exit 0 - ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/ss" <<'MOCK' -#!/usr/bin/env bash -printf 'udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("unbound",pid=1,fd=3))\n' -MOCK - - cat > "${BIN_SANDBOX}/dig" <<'MOCK' -#!/usr/bin/env bash -if [[ "$*" == *"google.com +short"* ]]; then - printf '142.250.190.14\n' - exit 0 -fi - -if [[ "$*" == *"cloudflare.com +dnssec"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 -;; flags: qr rd ra; QUERY: 1, ANSWER: 1 -OUT - exit 0 -fi - -if [[ "$*" == *"dnssec-failed.org"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 -OUT - exit 0 -fi - -if [[ "$*" == *"doubleclick.net"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 1 -OUT - exit 0 -fi - -cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 -;; Query time: 12 msec -OUT -MOCK - - cat > "${BIN_SANDBOX}/ps" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - cat > "${BIN_SANDBOX}/id" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "unbound" ]]; then - exit 0 -fi -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/getent" <<'MOCK' -#!/usr/bin/env bash -if [[ "${2:-}" == "unbound" ]]; then - exit 0 -fi -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/systemctl" <<'MOCK' -#!/usr/bin/env bash -case "${1:-}" in - is-enabled) - exit 0 - ;; - is-active) - exit 1 - ;; - *) - exit 0 - ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/stat" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-c" && "${2:-}" == "%U:%G" ]]; then - printf 'root:root\n' -elif [[ "${1:-}" == "-c" && "${2:-}" == "%a" ]]; then - printf '755\n' -fi -MOCK - - cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' -#!/usr/bin/env bash -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/find" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - chmod +x \ - "${BIN_SANDBOX}/sudo" \ - "${BIN_SANDBOX}/unbound-checkconf" \ - "${BIN_SANDBOX}/unbound-control" \ - "${BIN_SANDBOX}/ss" \ - "${BIN_SANDBOX}/dig" \ - "${BIN_SANDBOX}/ps" \ - "${BIN_SANDBOX}/id" \ - "${BIN_SANDBOX}/getent" \ - "${BIN_SANDBOX}/systemctl" \ - "${BIN_SANDBOX}/stat" \ - "${BIN_SANDBOX}/pgrep" \ - "${BIN_SANDBOX}/find" - - run env \ - PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ - PH_TEST_TIMEOUT_SECONDS=1 \ - PH_TEST_UNBOUND_CONF="${conf_file}" \ - PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" - assert_failure - assert_output --partial "Summary" - assert_output --partial "Could not retrieve stats (timed out after 1s)" - assert_output --partial "Found 1 failing check(s)." -} - -@test "ph-test still prints summary when stats output omits some keys" { - local conf_file="${TEST_TMPDIR}/unbound.conf" - - cat > "${conf_file}" <<'CONF' -server: - interface: 127.0.0.1 - hide-version: yes - hide-identity: yes - harden-glue: yes - harden-dnssec-stripped: yes -CONF - - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail - -if [[ "${1:-}" == */ph-test ]]; then - script_path="${1}" - shift - - tmp_script="$(mktemp)" - awk ' - /^# Self-elevate if not root/ { skip=1; next } - skip && /^DNS_SERVER=/ { skip=0 } - !skip { - print - } - ' "${script_path}" > "${tmp_script}" - - chmod +x "${tmp_script}" - exec "${tmp_script}" "$@" -fi - -exec "$@" -MOCK - - cat > "${BIN_SANDBOX}/unbound-checkconf" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-o" && "${2:-}" == "config-file" ]]; then - printf '%s\n' "${PH_TEST_UNBOUND_MAIN_CONF}" - exit 0 -fi - -printf 'no errors\n' -MOCK - - cat > "${BIN_SANDBOX}/unbound-control" <<'MOCK' -#!/usr/bin/env bash -case "${1:-}" in - status) - printf 'version 1.0\n' - printf 'daemon is running\n' - ;; - stats_noreset) - cat <<'OUT' -total.num.queries=42 -total.num.cachehits=10 -OUT - ;; - *) - exit 0 - ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/ss" <<'MOCK' -#!/usr/bin/env bash -printf 'udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("unbound",pid=1,fd=3))\n' -MOCK - - cat > "${BIN_SANDBOX}/dig" <<'MOCK' -#!/usr/bin/env bash -if [[ "$*" == *"google.com +short"* ]]; then - printf '142.250.190.14\n' - exit 0 -fi - -if [[ "$*" == *"cloudflare.com +dnssec"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 -;; flags: qr rd ra; QUERY: 1, ANSWER: 1 -OUT - exit 0 -fi - -if [[ "$*" == *"dnssec-failed.org"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 1 -OUT - exit 0 -fi - -if [[ "$*" == *"doubleclick.net"* ]]; then - cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 1 -OUT - exit 0 -fi - -cat <<'OUT' -;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1 -;; Query time: 12 msec -OUT -MOCK - - cat > "${BIN_SANDBOX}/ps" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - cat > "${BIN_SANDBOX}/id" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "unbound" ]]; then - exit 0 -fi -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/getent" <<'MOCK' -#!/usr/bin/env bash -if [[ "${2:-}" == "unbound" ]]; then - exit 0 -fi -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/systemctl" <<'MOCK' -#!/usr/bin/env bash -case "${1:-}" in - is-enabled) - exit 0 - ;; - is-active) - exit 1 - ;; - *) - exit 0 - ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/stat" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1:-}" == "-c" && "${2:-}" == "%U:%G" ]]; then - printf 'root:root\n' -elif [[ "${1:-}" == "-c" && "${2:-}" == "%a" ]]; then - printf '755\n' -fi -MOCK - - cat > "${BIN_SANDBOX}/pgrep" <<'MOCK' -#!/usr/bin/env bash -exit 1 -MOCK - - cat > "${BIN_SANDBOX}/find" <<'MOCK' -#!/usr/bin/env bash -exit 0 -MOCK - - chmod +x \ - "${BIN_SANDBOX}/sudo" \ - "${BIN_SANDBOX}/unbound-checkconf" \ - "${BIN_SANDBOX}/unbound-control" \ - "${BIN_SANDBOX}/ss" \ - "${BIN_SANDBOX}/dig" \ - "${BIN_SANDBOX}/ps" \ - "${BIN_SANDBOX}/id" \ - "${BIN_SANDBOX}/getent" \ - "${BIN_SANDBOX}/systemctl" \ - "${BIN_SANDBOX}/stat" \ - "${BIN_SANDBOX}/pgrep" \ - "${BIN_SANDBOX}/find" - - run env \ - PATH="${BIN_SANDBOX}:/usr/bin:/bin" \ - PH_TEST_UNBOUND_CONF="${conf_file}" \ - PH_TEST_UNBOUND_MAIN_CONF="${conf_file}" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" - assert_success - assert_output --partial "Summary" - assert_output --partial "total.num.queries: 42" - assert_output --partial "Passed:" -} - -@test "ph-test as root searches only system directories, not the caller's PATH" { - local probe_script="${TEST_TMPDIR}/ph-test" - local dir - - for dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do - [[ ! -e "${dir}/unbound-control" ]] || skip "unbound is installed on this host" - done - - # Take the root branch without being root. - sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-test" > "${probe_script}" - chmod +x "${probe_script}" - - local cmd - for cmd in dig unbound-control unbound-checkconf ss gum sudo; do - printf '#!/usr/bin/env bash\ntouch "%s/user-path-used"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/${cmd}" - chmod +x "${BIN_SANDBOX}/${cmd}" - done - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${probe_script}" - assert_failure - assert_output --partial "Missing dependency: unbound-control" - [[ ! -e "${TEST_TMPDIR}/user-path-used" ]] -} - -@test "ph-backup displays help" { - run "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" --help - assert_success - assert_output --partial "Usage:" - assert_output --partial "ph-backup [-o|--output DIR]" - assert_output --partial "~/backups/ph-backup" -} - -@test "ph-backup rejects unknown arguments before elevating" { - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -printf 'sudo %s\n' "$*" -MOCK - - chmod +x "${BIN_SANDBOX}/sudo" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" --bogus - assert_failure - assert_output --partial "Unknown argument: --bogus" - refute_output --partial "sudo " -} - -@test "ph-backup refuses to run off Linux" { - run env OSTYPE="darwin23" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" - assert_failure - assert_output --partial "only runs on the Linux host" -} - -@test "ph-backup self-elevates through sudo and preserves the output directory" { - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -printf 'sudo %s\n' "$*" -MOCK - - chmod +x "${BIN_SANDBOX}/sudo" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" -o /mnt/nas/pihole - assert_success - refute_output --partial "PATH=" - assert_output --partial "sudo ${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup -o /mnt/nas/pihole" -} - -# ph-backup must run as root, so tests exercise a copy with the self-elevation -# block stripped out. Mocks stand in for Pi-hole, Unbound, Tailscale, and the -# GNU flags (stat -c, numfmt) that the Linux host provides but macOS does not. -setup_ph_backup_sandbox() { - local teleporter_status="${1:-0}" - - cat > "${BIN_SANDBOX}/pihole-FTL" < etc/pihole/pihole.toml - printf 'hosts\n' > etc/hosts - zip -qr "pi-hole_pi_teleporter_2026-01-01.zip" etc - rm -rf etc - printf 'pi-hole_pi_teleporter_2026-01-01.zip\n' - ;; - --config) printf '["127.0.0.1#5335"]\n' ;; - sqlite3) - case "\${3}" in - *gravity*) printf '8843595\n' ;; - *) printf '9\n' ;; - esac - ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/pihole" <<'MOCK' -#!/usr/bin/env bash -[[ "${1}" == "-v" ]] && printf 'Core version is v6.1.4\n' -MOCK - - # Peer "m" carries Mullvad's shared-exit-node tag and must be filtered out. - cat > "${BIN_SANDBOX}/tailscale" <<'MOCK' -#!/usr/bin/env bash -if [[ "${1}" == "status" && "${2}" == "--json" ]]; then - printf '{"Version":"1.90.2","BackendState":"Running","MagicDNSSuffix":"tail1.ts.net",' - printf '"CurrentTailnet":{"Name":"example.com"},' - printf '"Self":{"HostName":"pi","TailscaleIPs":["100.64.0.5"]},' - printf '"Peer":{"a":{"HostName":"mac","Online":true},"b":{"HostName":"nas","Online":false},' - printf '"m":{"HostName":"mullvad-lax","Online":true,"Tags":["tag:mullvad-exit-node"]}}}\n' -fi -MOCK - - cat > "${BIN_SANDBOX}/unbound-checkconf" < "${BIN_SANDBOX}/sqlite3" <<'MOCK' -#!/usr/bin/env bash -case "${2}" in - *gravity*) printf '128432\n' ;; - *) printf '7\n' ;; -esac -MOCK - - cat > "${BIN_SANDBOX}/hostname" <<'MOCK' -#!/usr/bin/env bash -printf 'pi\n' -MOCK - - cat > "${BIN_SANDBOX}/stat" <<'MOCK' -#!/usr/bin/env bash -[[ "${1}" == "-c" && "${2}" == "%s" ]] && exec /usr/bin/stat -f%z "${3}" -exec /usr/bin/stat "$@" -MOCK - - chmod +x "${BIN_SANDBOX}/pihole-FTL" "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/tailscale" \ - "${BIN_SANDBOX}/unbound-checkconf" "${BIN_SANDBOX}/sqlite3" "${BIN_SANDBOX}/hostname" \ - "${BIN_SANDBOX}/stat" - - mkdir -p "${TEST_TMPDIR}/etc/unbound/unbound.conf.d" "${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/var/lib/unbound" "${TEST_TMPDIR}/out" - printf 'server:\n port: 5335\n' > "${TEST_TMPDIR}/etc/unbound/unbound.conf.d/pi-hole.conf" - printf 'gravity\n' > "${TEST_TMPDIR}/etc/pihole/gravity.db" - # The trust anchor lives outside the config directory, as on Debian. - printf 'anchor\n' > "${TEST_TMPDIR}/var/lib/unbound/root.key" - - awk ' - /^# Self-elevate if not root/ { skip = 1 } - skip && /^fi$/ { skip = 0; next } - skip { next } - { print } - ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" > "${TEST_TMPDIR}/ph-backup" - - chmod +x "${TEST_TMPDIR}/ph-backup" -} - -run_ph_backup_probe() { - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - PH_BACKUP_SBIN_PATHS="" \ - PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ - PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" -} - -@test "ph-backup archives the three backups plus a summary" { - setup_ph_backup_sandbox - - run_ph_backup_probe - assert_success - assert_output --partial "Teleporter archive captured" - assert_output --partial "Tailscale state captured" - assert_output --partial "SUMMARY.md written" - assert_output --partial "Failed: 0" - - local archive - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - [[ -n "${archive}" ]] - - run tar -tzf "${archive}" - assert_success - assert_output --partial "SUMMARY.md" - assert_output --partial "pi-hole_pi_teleporter_2026-01-01.zip" - assert_output --partial "unbound-" - assert_output --partial "tailscale-status-" -} - -@test "ph-backup summary describes all three sources" { - setup_ph_backup_sandbox - - run_ph_backup_probe - assert_success - - local archive - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - - run tar -xzOf "${archive}" '*/SUMMARY.md' - assert_success - assert_output --partial "Core version is v6.1.4" - assert_output --partial "Gravity domains: 128432" - assert_output --partial '127.0.0.1#5335' - assert_output --partial "Backend state: Running" - assert_output --partial "Tailnet: example.com" - assert_output --partial "Peers: 2 total, 1 online" - assert_output --partial "Keep it private" -} - -@test "ph-backup maps every archived path to a restore destination" { - setup_ph_backup_sandbox - - run_ph_backup_probe - assert_success - - local archive - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - - run tar -xzOf "${archive}" '*/SUMMARY.md' - assert_success - assert_output --partial "Restore map" - - # Every path inside the Teleporter zip and the Unbound tarball is listed - # with the absolute path it belongs at. - assert_output --partial '| `etc/pihole/pihole.toml` | `/etc/pihole/pihole.toml` |' - assert_output --partial '| `etc/hosts` | `/etc/hosts` |' - - # Unbound paths are stored relative to / so each maps to one destination. - local rel="${TEST_TMPDIR#/}" - assert_output --partial '| `'"${rel}"'/etc/unbound/unbound.conf.d/pi-hole.conf` | `'"${TEST_TMPDIR}"'/etc/unbound/unbound.conf.d/pi-hole.conf` |' - assert_output --partial '| `'"${rel}"'/var/lib/unbound/root.key` | `'"${TEST_TMPDIR}"'/var/lib/unbound/root.key` |' - - # Artifacts that restore nowhere say so outright. - assert_output --partial "Restores to: nothing" -} - -@test "ph-backup contents table names a destination for every file" { - setup_ph_backup_sandbox - printf 'server:\n' > "${TEST_TMPDIR}/etc/unbound/unbound.conf" - - run_ph_backup_probe - assert_success - - local archive - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - - run tar -xzOf "${archive}" '*/SUMMARY.md' - assert_success - assert_output --partial "| File | Size | Restores to |" - - # Teleporter keeps /etc and /etc/pihole distinct; a top-level directory is - # too broad to stand in for its children. - assert_output --partial '| `/etc`, `/etc/pihole` |' - - # Unbound collapses its subdirectory into the config root. - assert_output --partial '`'"${TEST_TMPDIR}"'/etc/unbound`, `'"${TEST_TMPDIR}"'/var/lib/unbound` |' - - # The two files that restore nowhere are listed, not omitted. - assert_output --partial "| nothing — reference only |" - assert_output --partial '| `SUMMARY.md` | — | nothing — this file |' - - # Checksums survive the move out of the contents table. - assert_output --partial "## Checksums" - assert_output --partial "sha256sum -c" -} - -@test "ph-backup includes the DNSSEC trust anchor from outside the config dir" { - setup_ph_backup_sandbox - - run_ph_backup_probe - assert_success - - local archive inner - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - inner="${TEST_TMPDIR}/inner" - mkdir -p "${inner}" - tar -xzf "${archive}" -C "${inner}" - - run tar -tzf "$(find "${inner}" -name 'unbound-*.tar.gz' -print -quit)" - assert_success - assert_output --partial "${TEST_TMPDIR#/}/etc/unbound/unbound.conf.d/pi-hole.conf" - assert_output --partial "${TEST_TMPDIR#/}/var/lib/unbound/root.key" -} - -@test "ph-backup drops Mullvad exit nodes from the Tailscale state" { - setup_ph_backup_sandbox - - run_ph_backup_probe - assert_success - - local archive inner state - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - inner="${TEST_TMPDIR}/inner" - mkdir -p "${inner}" - tar -xzf "${archive}" -C "${inner}" - state="$(find "${inner}" -name 'tailscale-status-*.json' -print -quit)" - - run jq -r '.Peer | keys | join(",")' "${state}" - assert_success - assert_output "a,b" - - run tar -xzOf "${archive}" '*/SUMMARY.md' - assert_output --partial "Peers: 2 total" - assert_output --partial "Filtered out: 1 shared Mullvad exit nodes" -} - -@test "ph-backup copies the archive off-box when a destination is given" { - setup_ph_backup_sandbox - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - PH_BACKUP_SBIN_PATHS="" \ - PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ - PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" --copy-to "${TEST_TMPDIR}/offbox" - assert_success - assert_output --partial "Archive copied to ${TEST_TMPDIR}/offbox" - - run find "${TEST_TMPDIR}/offbox" -name 'ph-backup-pi-*.tar.gz' - assert_output --partial "ph-backup-pi-" -} - -@test "ph-backup warns without failing when the off-box copy cannot be written" { - setup_ph_backup_sandbox - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - PH_BACKUP_SBIN_PATHS="" \ - PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ - PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" \ - --copy-to "${TEST_TMPDIR}/etc/pihole/gravity.db/nope" - assert_success - assert_output --partial "Could not copy the archive" - assert_output --partial "Warnings: 1" -} - -@test "ph-backup reads gravity stats through pihole-FTL when sqlite3 cannot" { - setup_ph_backup_sandbox - - # The Pi has no sqlite3 package at all; a stub that returns nothing stands - # in for that, since the host running these tests has its own sqlite3. - cat > "${BIN_SANDBOX}/sqlite3" <<'MOCK' -#!/usr/bin/env bash -exit 1 -MOCK - - chmod +x "${BIN_SANDBOX}/sqlite3" - - run_ph_backup_probe - assert_success - - local archive - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - - run tar -xzOf "${archive}" '*/SUMMARY.md' - assert_success - assert_output --partial "Gravity domains: 8843595" - assert_output --partial "Enabled adlists: 9" - refute_output --partial "Gravity statistics: unavailable" -} - -@test "ph-backup warns but still archives when Tailscale and Unbound are missing" { - setup_ph_backup_sandbox - rm "${BIN_SANDBOX}/tailscale" - rm -rf "${TEST_TMPDIR}/etc/unbound" - - run_ph_backup_probe - assert_success - assert_output --partial "Could not capture Tailscale state" - assert_output --partial "Could not archive" - assert_output --partial "Warnings: 2" - - local archive - archive="$(find "${TEST_TMPDIR}/out" -name 'ph-backup-pi-*.tar.gz' -print -quit)" - [[ -n "${archive}" ]] -} - -@test "ph-backup writes no archive when the Teleporter export fails" { - setup_ph_backup_sandbox 1 - - run_ph_backup_probe - assert_failure - assert_output --partial "Teleporter export failed" - assert_output --partial "Run pihole-FTL --teleporter manually" - - run find "${TEST_TMPDIR}/out" -name '*.tar.gz' - assert_output "" -} - -@test "ph-backup as root searches only system directories, not the caller's PATH" { - local probe_script="${TEST_TMPDIR}/ph-backup-root" - local dir - - for dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do - [[ ! -e "${dir}/pihole-FTL" && ! -e "${dir}/pihole" ]] || skip "Pi-hole is installed on this host" - done - - # Take the root branch without being root. - sed 's/if \[\[ "${EUID}" -ne 0 \]\]; then/if false; then/' \ - "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" > "${probe_script}" - chmod +x "${probe_script}" - - local cmd - for cmd in pihole-FTL pihole hostname tar gum sudo; do - printf '#!/usr/bin/env bash\ntouch "%s/user-path-used"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/${cmd}" - chmod +x "${BIN_SANDBOX}/${cmd}" - done - mkdir -p "${TEST_TMPDIR}/out" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - "${probe_script}" -o "${TEST_TMPDIR}/out" - assert_failure - assert_output --partial "neither pihole-FTL nor pihole is on PATH" - [[ ! -e "${TEST_TMPDIR}/user-path-used" ]] -} - -# Pin the snapshot timestamp so tests can plant files at the archive name. -mock_ph_backup_date() { - cat > "${BIN_SANDBOX}/date" <<'MOCK' -#!/usr/bin/env bash -[[ "${1:-}" == "+%Y%m%d-%H%M%S" ]] && { printf '20260101-000000\n'; exit 0; } -exec /bin/date "$@" -MOCK - chmod +x "${BIN_SANDBOX}/date" -} - -@test "ph-backup creates a missing output directory owner-only with a 0600 archive" { - setup_ph_backup_sandbox - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - PH_BACKUP_SBIN_PATHS="" \ - PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ - PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/fresh/backups" - assert_success - - run find "${TEST_TMPDIR}/fresh/backups" -maxdepth 0 -perm 0700 - assert_output "${TEST_TMPDIR}/fresh/backups" - - run find "${TEST_TMPDIR}/fresh/backups" -name 'ph-backup-pi-*.tar.gz' -perm 0600 - assert_output --partial "ph-backup-pi-" - - # The temporary file was renamed into place, not left behind. - run find "${TEST_TMPDIR}/fresh/backups" -name '.tmp.*' - assert_output "" -} - -@test "ph-backup refuses a symlinked or shared-writable output directory" { - setup_ph_backup_sandbox - mkdir -p "${TEST_TMPDIR}/real-out" - ln -s "${TEST_TMPDIR}/real-out" "${TEST_TMPDIR}/link-out" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - PH_BACKUP_SBIN_PATHS="" \ - PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ - PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/link-out" - assert_failure - assert_output --partial "Refusing ${TEST_TMPDIR}/link-out: it is a symlink" - assert_output --partial "Could not write the backup archive" - - run find "${TEST_TMPDIR}/real-out" -type f - assert_output "" - - chmod 777 "${TEST_TMPDIR}/out" - run_ph_backup_probe - assert_failure - assert_output --partial "it is writable by group or others" - - run find "${TEST_TMPDIR}/out" -type f - assert_output "" -} - -@test "ph-backup never writes through a symlink planted at the archive name" { - setup_ph_backup_sandbox - mock_ph_backup_date - printf 'victim\n' > "${TEST_TMPDIR}/victim" - ln -s "${TEST_TMPDIR}/victim" "${TEST_TMPDIR}/out/ph-backup-pi-20260101-000000.tar.gz" - - run_ph_backup_probe - assert_failure - assert_output --partial "Refusing to replace existing" - - run cat "${TEST_TMPDIR}/victim" - assert_output "victim" - [[ -L "${TEST_TMPDIR}/out/ph-backup-pi-20260101-000000.tar.gz" ]] - - run find "${TEST_TMPDIR}/out" -name '.tmp.*' - assert_output "" -} - -@test "ph-backup off-box copy never writes through a planted symlink" { - setup_ph_backup_sandbox - mock_ph_backup_date - mkdir -p "${TEST_TMPDIR}/offbox" - printf 'victim\n' > "${TEST_TMPDIR}/victim" - ln -s "${TEST_TMPDIR}/victim" "${TEST_TMPDIR}/offbox/ph-backup-pi-20260101-000000.tar.gz" - - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" \ - PH_BACKUP_SBIN_PATHS="" \ - PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ - PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" --copy-to "${TEST_TMPDIR}/offbox" + run bash -c "cd '${TEST_TMPDIR}' && $(cat "${ACME_HOME}/reloadcmd")" assert_success - assert_output --partial "Could not copy the archive" - - run cat "${TEST_TMPDIR}/victim" - assert_output "victim" - - run find "${TEST_TMPDIR}/offbox" -name '.tmp.*' - assert_output "" + run cat "${dir}/a.test.pem" + assert_output "$(printf 'KEY\nCHAIN')" + run ls -l "${dir}/a.test.pem" + assert_output --regexp '^-rw-------' + [ ! -e "${TEST_TMPDIR}/PWNED" ] && [ ! -e "${TEST_TMPDIR}/PWNED2" ] } -@test "ph-backup writes the archive and scp copy as the invoking user" { - setup_ph_backup_sandbox - mkdir -p "${TEST_TMPDIR}/remote" - - cat > "${BIN_SANDBOX}/sudo" <<'MOCK' -#!/usr/bin/env bash -printf 'sudo %s\n' "$*" -[[ "${1:-}" == "-u" ]] && shift 2 -exec "$@" -MOCK - - # Stand in for the remote end, and report what scp was allowed to read. - cat > "${BIN_SANDBOX}/scp" <<'MOCK' -#!/usr/bin/env bash -src="${2}" -[[ -O "${src}" ]] && printf 'scp source owned by caller\n' -[[ -n "$(find "${src}" -maxdepth 0 -perm 0600)" ]] && printf 'scp source mode 0600\n' -cp "${src}" "${TEST_TMPDIR}/remote/" -MOCK +@test "cf-cert --pihole as root points Pi-hole at the pem and restarts it on renewal" { + setup_cf_cert_sandbox + sed 's/(( EUID == 0 )) || die "--pihole needs root/true || die "--pihole needs root/' "${CF_CERT}" > "${TEST_TMPDIR}/cf-cert" + chmod +x "${TEST_TMPDIR}/cf-cert" - chmod +x "${BIN_SANDBOX}/sudo" "${BIN_SANDBOX}/scp" + # Without pihole-FTL it stops before acme.sh runs. + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${TEST_TMPDIR}/cf-cert" --pihole a.test + assert_failure 1 + assert_output --partial "pihole-FTL not found" + [ ! -e "${ACME_HOME}/calls.log" ] - run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" OSTYPE="linux-gnu" TEST_TMPDIR="${TEST_TMPDIR}" \ - SUDO_USER="$(id -un)" \ - PH_BACKUP_SBIN_PATHS="" \ - PH_BACKUP_UNBOUND_DIR="${TEST_TMPDIR}/etc/unbound" \ - PH_BACKUP_PIHOLE_DIR="${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" --copy-to nas:/backups + printf '#!/usr/bin/env bash\nprintf "pihole-FTL %%s\\n" "$*" >> "%s/ftl.log"\n' "${TEST_TMPDIR}" > "${BIN_SANDBOX}/pihole-FTL" + chmod +x "${BIN_SANDBOX}/pihole-FTL" + run env PATH="${BIN_SANDBOX}:/usr/bin:/bin" "${TEST_TMPDIR}/cf-cert" --pihole a.test assert_success - assert_output --partial "Archive copied to nas:/backups" - # The archive lands through a shell run as the invoking user, never root. - assert_output --partial "sudo -u $(id -un) sh -c" - assert_output --regexp "sudo -u $(id -un) scp -q ${TEST_TMPDIR}/out/ph-backup-pi-[^ ]*\.tar\.gz nas:/backups" - assert_output --partial "scp source owned by caller" - assert_output --partial "scp source mode 0600" - - run find "${TEST_TMPDIR}/remote" -name 'ph-backup-pi-*.tar.gz' - assert_output --partial "ph-backup-pi-" + run cat "${TEST_TMPDIR}/ftl.log" + assert_output "pihole-FTL --config webserver.domain a.test +pihole-FTL --config webserver.tls.cert /etc/pihole/a.test.pem" + run grep -- "--install-cert" "${ACME_HOME}/calls.log" + assert_output "--home ${ACME_HOME} --install-cert -d a.test --ecc --key-file /etc/pihole/a.test.key --fullchain-file /etc/pihole/a.test.fullchain --reloadcmd cat /etc/pihole/a.test.key /etc/pihole/a.test.fullchain > /etc/pihole/a.test.pem && chmod 600 /etc/pihole/a.test.pem /etc/pihole/a.test.key && chown pihole:pihole /etc/pihole/a.test.pem /etc/pihole/a.test.key && systemctl restart pihole-FTL" } @test "ts-test displays help" { @@ -3329,8 +1346,10 @@ fake_df_repo() { # Sets up a fake remote machine for df-deploy: an origin repo with a link.sh that logs its # calls, the remote home's clone of it, and a stub ssh that records its arguments and runs the -# remote command locally as that home. +# remote command locally as that home. Git ignores the real home's config, whose commit +# signing would be slow or prompt. df_deploy_sandbox() { + export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 local g=(git -c user.name=t -c user.email=t@example.com -c init.defaultBranch=main) DF_ORIGIN="${TEST_TMPDIR}/origin" DF_REMOTE_HOME="${TEST_TMPDIR}/remote-home" @@ -3623,722 +1642,3 @@ PY assert_success assert_output "[here] cd $(pwd -P) && claude" } - -setup_firetv_sandbox() { - FIRETV="${PROJECT_ROOT}/stow/personal/.local/bin/firetv" - export FIRETV_STATE="${TEST_TMPDIR}/tv" - export FIRETV_STATE_DIR="${TEST_TMPDIR}/state" - # Never the real Pi-hole: this ph refuses until setup_firetv_pihole replaces it. - export PH_BIN="${BIN_SANDBOX}/ph" - export FIRETV_TWEAKS_FILE="${TEST_TMPDIR}/no-tweaks" - printf '%s\n' '#!/usr/bin/env bash' \ - "printf 'ph: no password: run '\\''ph login'\\'' or set PIHOLE_PASSWORD\\n' >&2" 'exit 1' > "${PH_BIN}" - chmod +x "${PH_BIN}" - mkdir -p "${FIRETV_STATE}" - # The TV: installed packages and disabled ones, one per line. - printf '%s\n' com.amazon.tv.acr com.amazon.device.metrics com.amazon.device.metrics \ - com.amazon.storm.lightning.tutorial com.amazon.tv.launcher > "${FIRETV_STATE}/installed" - printf '%s\n' com.amazon.storm.lightning.tutorial > "${FIRETV_STATE}/disabled" - cat > "${BIN_SANDBOX}/adb" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> "${FIRETV_STATE}/calls.log" -[[ "${1:-}" == -s ]] && shift 2 -# Like real adb shell, drain stdin. -[[ "${1:-}" != shell ]] || cat > /dev/null -if [[ "$*" == "shell pm list packages"* && -e "${FIRETV_STATE}/pm-broken" ]]; then - printf 'cmd: Failure calling service package\n' - exit 20 -fi -case "$*" in - "connect "*) printf 'connected to %s\n' "${2}" ;; - get-state) - if [[ -e "${FIRETV_STATE}/offline" ]]; then printf 'offline\n'; else printf '%s\n' "${ADB_STATE:-device}"; fi ;; - "disconnect "*) rm -f "${FIRETV_STATE}/offline" ;; - "shell getprop ro.product.model") printf 'AFTDEC012E\r\n' ;; - "shell getprop ro.build.version.name") printf 'Fire OS 8.1.8.5 (RS8185/3885)\r\n' ;; - "shell pm list packages -3 --show-versioncode") - printf 'package:%s\r\n' 'com.netflix.ninja versionCode:27207' 'com.amazon.tv.settings.v2.overlay.localisation.common versionCode:0' \ - $({ grep -v -E '^(com\.amazon|com\.example)' "${FIRETV_STATE}/installed" || true; } | sed 's/$/ versionCode:1/' | tr ' ' '#') | tr '#' ' ' ;; - "shell pm list packages -d") sed 's/^/package:/' "${FIRETV_STATE}/disabled" ;; - "shell pm list packages"*) sed 's/^/package:/' "${FIRETV_STATE}/installed" ;; - # Settings as "NAMESPACE KEY VALUE" lines, the last one winning; "-" deletes. - "shell settings put "*) printf '%s %s %s\n' "$4" "$5" "$6" >> "${FIRETV_STATE}/settings" ;; - "shell settings delete "*) printf '%s %s -\n' "$4" "$5" >> "${FIRETV_STATE}/settings" ;; - "shell settings get "*) - value="$(awk -v ns="$4" -v k="$5" '$1 == ns && $2 == k { v = $3 } END { print v }' "${FIRETV_STATE}/settings" 2>/dev/null || true)" - [[ "${value}" != - ]] || value="" - printf '%s\r\n' "${value:-null}" ;; - "shell settings list "*) - awk -v ns="$4" '$1 == ns { if (!($2 in v)) o[++n] = $2; v[$2] = $3 } END { for (i = 1; i <= n; i++) if (v[o[i]] != "-") printf "%s=%s\r\n", o[i], v[o[i]] }' \ - "${FIRETV_STATE}/settings" 2>/dev/null || true ;; - "install -r "*) - [[ "$3" != *broken* ]] || { printf 'Failure [INSTALL_PARSE_FAILED_NOT_APK]\n'; exit 1; } - basename "$3" .apk >> "${FIRETV_STATE}/installed" - printf 'Performing Streamed Install\r\nSuccess\r\n' ;; - "uninstall "*) - grep -vxF "$2" "${FIRETV_STATE}/installed" > "${FIRETV_STATE}/i.tmp" || true - mv "${FIRETV_STATE}/i.tmp" "${FIRETV_STATE}/installed" - printf 'Success\r\n' ;; - "shell df -k /data") - printf 'Filesystem 1K-blocks Used Available Use%% Mounted on\r\n/dev/block/dm-17 9339420 2299140 %s 26%% /data\r\n' \ - "$(cat "${FIRETV_STATE}/free" 2>/dev/null || printf 6892824)" ;; - "shell pm trim-caches 999G") printf '%s\n' "$(( $(cat "${FIRETV_STATE}/free" 2>/dev/null || printf 6892824) + 204800 ))" > "${FIRETV_STATE}/free" ;; - "shell dumpsys package com.amazon.venezia") printf ' Package [com.amazon.venezia]\r\n userId=10171\r\n' ;; - "shell dumpsys package com.amazon.device.metrics") - printf ' userId=10194\r\n versionCode=%s minSdk=30 targetSdk=28\r\n' "$(cat "${FIRETV_STATE}/metrics-version" 2>/dev/null || printf 1)" ;; - "shell dumpsys package "*) printf 'Unable to find package\r\n' ;; - "shell pm path com.amazon.venezia") - printf 'package:/system_ext/priv-app/com.amazon.venezia/com.amazon.venezia.apk\r\n' ;; - "shell pm path com.amazon.ftvads.deeplinking") - printf 'package:/system_ext/app/FtvAds/FtvAds.apk\r\n' ;; - "shell pm path com.amazon.tv.acr") printf 'package:/system_ext/app/Acr/Acr.apk\r\n' ;; - "shell pm path "*) ;; - *"for a in /system_ext/priv-app/com.amazon.venezia/com.amazon.venezia.apk;"*"strings -n 6"*) - printf '%s\r\n' appstore-tv-prod-na.amazon.com unagi-na.amazon.com 0com.amazon.dev \ - https://juggler.amazon.com ec2.eu-west-1.amazonaws.com ec2.us-east-1.amazonaws.com ;; - *"for a in /system_ext/app/FtvAds/FtvAds.apk;"*"strings -n 6"*) - printf '%s\r\n' mads.amazon.com aax-us-east.amazon-adsystem.com api.amazon.com never-looked-up.amazon.com ;; - *"strings -n 6"*) exit 1 ;; - *"/proc/net/tcp"*"-v u=10194 "*) - [[ ! -e "${FIRETV_STATE}/metrics-connects" ]] || printf '%s\r\n' 0000000000000000FFFF0000D3B1A736:01BB ;; - *"/proc/net/tcp"*) - [[ "$*" == *"-v u=10171 "* ]] || exit 1 - # 54.167.177.211:443 mapped into IPv6, then loopback, which drops. - printf '%s\r\n' 0000000000000000FFFF0000D3B1A736:01BB 0100007F:1F90 ;; - "shell pm disable-user --user 0 com.amazon.device.metrics") - printf 'java.lang.SecurityException: Shell cannot change component state\n' ;; - "shell pm disable-user --user 0 "*) - printf '%s\n' "${6}" >> "${FIRETV_STATE}/disabled" - printf 'Package %s new state: disabled-user\r\n' "${6}" ;; - "shell pm enable "*) - grep -vxF "${4}" "${FIRETV_STATE}/disabled" > "${FIRETV_STATE}/d.tmp" || true - mv "${FIRETV_STATE}/d.tmp" "${FIRETV_STATE}/disabled" - printf 'Package %s new state: enabled\n' "${4}" ;; - *) printf 'adb %s\n' "$*" ;; -esac -MOCK - chmod +x "${BIN_SANDBOX}/adb" -} - -@test "firetv shows help and rejects bad commands and groups" { - run "${PROJECT_ROOT}/stow/personal/.local/bin/firetv" --help - assert_success - assert_output --partial "disable TARGET..." - run "${PROJECT_ROOT}/stow/personal/.local/bin/firetv" - assert_failure 2 - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" bogus - assert_failure - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable - assert_failure - assert_output --partial "Name a group (telemetry, ads, bloat, all) or a package" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable nope - assert_failure - assert_output --partial "Unknown group or package: nope" - [[ ! -e "${FIRETV_STATE}/calls.log" ]] -} - -@test "firetv stops when the TV has not authorized this machine" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" ADB_STATE="error: device unauthorized" "${FIRETV}" list - assert_failure - assert_output --partial "Accept the prompt on the TV" -} - -@test "firetv list reports enabled, disabled, and absent packages" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --host 10.0.0.5 list bloat telemetry - assert_success - assert_line --regexp '^enabled +telemetry +com\.amazon\.tv\.acr ' - assert_line --regexp '^disabled +bloat +com\.amazon\.storm\.lightning\.tutorial ' - assert_line --regexp '^absent +telemetry +com\.amazon\.perfc ' - run grep -c -- '-s 10.0.0.5:5555' "${FIRETV_STATE}/calls.log" - refute_output "0" -} - -@test "firetv has no separate status or disabled command" { - setup_firetv_sandbox - for cmd in status disabled; do - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" "${cmd}" - assert_failure - assert_output --partial "Unknown command: ${cmd}" - done -} - -@test "firetv disable tries every package, skipping absent, done, and root-only ones" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --dry-run disable telemetry - assert_success - assert_output --partial "would disable com.amazon.tv.acr" - run grep -- 'disable-user' "${FIRETV_STATE}/calls.log" - assert_failure - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable all all telemetry - assert_success - assert_output --partial "com.amazon.device.metrics: needs root" - assert_output --partial "com.amazon.storm.lightning.tutorial: already disabled" - assert_output --partial "com.amazon.perfc: not on this TV" - assert_output --partial "1 disabled, 16 skipped, 1 need root, 0 failed" - run grep -c -- 'disable-user' "${FIRETV_STATE}/calls.log" - assert_output "2" - run grep -cxF com.amazon.tv.launcher "${FIRETV_STATE}/disabled" - assert_output "0" - - # A rerun changes nothing and still passes. - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable telemetry - assert_success - assert_output --partial "0 disabled" - run grep -c -- 'disable-user' "${FIRETV_STATE}/calls.log" - assert_output "3" -} - -@test "firetv enables and disables single packages, and refuses to disable core ones" { - setup_firetv_sandbox - printf '%s\n' com.amazon.media.recommendations com.example.extra >> "${FIRETV_STATE}/installed" - printf '%s\n' com.amazon.media.recommendations >> "${FIRETV_STATE}/disabled" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable com.amazon.media.recommendations - assert_success - assert_output --partial "com.amazon.media.recommendations: enabled ((not on the debloat list))" - assert_output --partial "1 enabled, 0 skipped" - run grep -cxF com.amazon.media.recommendations "${FIRETV_STATE}/disabled" - assert_output "0" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable com.amazon.media.recommendations - assert_failure - assert_output --partial "com.amazon.media.recommendations is a core package the TV needs" - run grep -c disable-user "${FIRETV_STATE}/calls.log" - assert_output "0" - - # A package name beside a group is tried once, even when the group has it. - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable com.example.extra telemetry com.amazon.tv.acr - assert_success - assert_output --partial "com.example.extra: disabled" - run grep -c 'disable-user --user 0 com.amazon.tv.acr$' "${FIRETV_STATE}/calls.log" - assert_output "1" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable 'com.x;reboot' - assert_failure - assert_output --partial "Unknown group or package" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" list - assert_success - refute_output --partial "media.recommendations" -} - -@test "firetv fails on an unexpected pm reply" { - setup_firetv_sandbox - printf '%s\n' com.amazon.gamehub >> "${FIRETV_STATE}/installed" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat - assert_success - sed -i.bak 's/new state: enabled/Unknown package/' "${BIN_SANDBOX}/adb" - printf '%s\n' com.amazon.gamehub >> "${FIRETV_STATE}/disabled" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat - assert_failure - assert_output --partial "com.amazon.gamehub: Package com.amazon.gamehub Unknown package" - assert_output --partial "1 failed" -} - -@test "firetv reconnects a connection left offline by a reboot" { - setup_firetv_sandbox - touch "${FIRETV_STATE}/offline" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" connect - assert_success - assert_output --partial "AFTDEC012E, Fire OS 8.1.8.5 (RS8185/3885)" - refute_output --partial "Fire OS Fire OS" - run grep -c '^disconnect ' "${FIRETV_STATE}/calls.log" - assert_output "1" -} - -@test "firetv says why when the package list cannot be read" { - setup_firetv_sandbox - touch "${FIRETV_STATE}/pm-broken" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable ads - assert_failure - assert_output --partial "Could not list packages on the TV" - run grep -- 'disable-user' "${FIRETV_STATE}/calls.log" - assert_failure -} - -@test "firetv rejects arguments to commands that take none" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" reboot now - assert_failure - assert_output --partial "reboot takes no arguments" - [[ ! -e "${FIRETV_STATE}/calls.log" ]] -} - -@test "firetv enable undoes a disable" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat - assert_success - assert_output --partial "com.amazon.storm.lightning.tutorial: enabled" - run cat "${FIRETV_STATE}/disabled" - assert_output "" -} - -@test "firetv backup saves the package list under its state dir" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" FIRETV_STATE_DIR="${TEST_TMPDIR}/backups" "${FIRETV}" backup - assert_success - run bash -c 'cat "$1"/packages-*.txt' _ "${TEST_TMPDIR}/backups" - assert_line "package:com.amazon.tv.launcher" -} - -@test "firetv doctor passes a debloated TV and remembers root-only packages" { - setup_firetv_sandbox - export FIRETV_STATE_DIR="${TEST_TMPDIR}/state" - printf '%s\n' com.amazon.tv.launcher com.amazon.tv.settings.v2 >> "${FIRETV_STATE}/installed" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor - assert_success - assert_output --partial "AFTDEC012E, Fire OS 8.1.8.5" - assert_output --partial "warning: telemetry: 0 of 2 disabled" - assert_output --partial "- firetv disable telemetry" - assert_output --partial "warning: No package backup yet" - assert_output --partial "ok: ads: none on this TV" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" disable telemetry - assert_success - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" backup - assert_success - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor - assert_success - assert_output --partial "ok: telemetry: 1 of 2 disabled" - assert_output --partial "warning: Need root, so still on: com.amazon.device.metrics; Pi-hole not checked" - assert_output --partial "ok: Backup: ${FIRETV_STATE_DIR}/packages-" - refute_output --partial "- firetv disable telemetry" - run grep -- 'disable-user\|pm enable' "${FIRETV_STATE}/calls.log" - assert_output --partial "com.amazon.tv.acr" - run grep -c -- 'disable-user' "${FIRETV_STATE}/calls.log" - assert_output "2" -} - -@test "firetv doctor fails on a disabled core package and says how to restore it" { - setup_firetv_sandbox - printf '%s\n' com.amazon.tv.launcher >> "${FIRETV_STATE}/disabled" - run env PATH="${BIN_SANDBOX}:${PATH}" FIRETV_STATE_DIR="${TEST_TMPDIR}/state" "${FIRETV}" doctor - assert_failure - assert_output --partial "failed: Core packages disabled: com.amazon.tv.launcher" - assert_output --partial "adb -s 192.168.0.84:5555 shell pm enable com.amazon.tv.launcher" -} - -@test "firetv doctor reports a missing adb or an unreachable TV without stopping silently" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" ADB_BIN=no-such-adb "${FIRETV}" doctor - assert_failure - assert_output --partial "failed: adb is not installed" - assert_output --partial "brew install --cask android-platform-tools" - - run env PATH="${BIN_SANDBOX}:${PATH}" ADB_STATE="error: device unauthorized" "${FIRETV}" doctor - assert_failure - assert_output --partial "Accept the prompt on the TV" - assert_output --partial "Failed: 1" -} - -setup_firetv_pihole() { - setup_firetv_sandbox - export PIHOLE_STATE="${TEST_TMPDIR}/pihole" - mkdir -p "${PIHOLE_STATE}" "${FIRETV_STATE_DIR}" - printf '%s\n' com.amazon.ftvads.deeplinking >> "${FIRETV_STATE}/installed" - printf '%s\n' com.amazon.ftvads.deeplinking com.amazon.device.metrics > "${FIRETV_STATE_DIR}/needs-root" - printf '%s\n' mads.amazon.com > "${PIHOLE_STATE}/blocked" - printf '%s\n' msh.amazon.com > "${PIHOLE_STATE}/denylist" - : > "${PIHOLE_STATE}/calls.log" - # ph's --json check and deny, against a deny list in a file. - cat > "${PH_BIN}" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> "${PIHOLE_STATE}/calls.log" -[[ "${1:-}" == --json ]] || { printf 'ph: test expects --json\n' >&2; exit 1; } -shift -case "$1" in - -n) - [[ "$3" == queries && "$4" == 192.168.0.84 ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } - jq -n '{queries: [ - {domain: "appstore-tv-prod-na.amazon.com", status: "FORWARDED"}, - {domain: "appstore-tv-prod-na.amazon.com", status: "CACHE"}, - {domain: "unagi-na.amazon.com", status: "GRAVITY"}, - {domain: "avs-alexa-19-na.amazon.com", status: "FORWARDED"}, - {domain: "mads.amazon.com", status: "GRAVITY"}, - {domain: "aax-us-east.amazon-adsystem.com", status: "FORWARDED"}, - {domain: "api.amazon.com", status: "CACHE"}, - {domain: "bad name;rm", status: "FORWARDED"}]}' - ;; - check) - # A Pi-hole that rate-limits the first login for a domain. - if [[ -n "${PH_LIMIT_ONCE:-}" && ! -e "${PIHOLE_STATE}/limited-$2" ]]; then - touch "${PIHOLE_STATE}/limited-$2" - printf 'ph: login failed: HTTP 429: Rate-limiting login attempts\n' >&2 - exit 1 - fi - if grep -qxF "$2" "${PIHOLE_STATE}/blocked"; then - printf '{"search":{"domains":[],"gravity":[{"domain":"%s","type":"block","enabled":true}]}}\n' "$2" - else - printf '{"search":{"domains":[],"gravity":[]}}\n' - fi - ;; - list) - [[ "$2" == deny ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } - jq -R -s '{domains: (split("\n") | map(select(. != "")) | map({domain: ., kind: "exact", type: "deny", enabled: true}))}' \ - "${PIHOLE_STATE}/denylist" - ;; - deny) - [[ "$2" == -c && -n "$3" ]] || { printf 'ph: test expects a comment\n' >&2; exit 1; } - shift 3 - printf '%s\n' "$@" | tee -a "${PIHOLE_STATE}/blocked" > "${PIHOLE_STATE}/denied" - printf '{"processed":{"success":[],"errors":[]}}\n' - ;; - *) printf 'ph: unexpected %s\n' "$*" >&2; exit 1 ;; -esac -MOCK - chmod +x "${PH_BIN}" -} - -@test "firetv doctor passes on ph's error and says to log in" { - setup_firetv_sandbox - mkdir -p "${FIRETV_STATE_DIR}" - printf '%s\n' com.amazon.ftvads.deeplinking > "${FIRETV_STATE_DIR}/needs-root" - printf '%s\n' com.amazon.ftvads.deeplinking >> "${FIRETV_STATE}/installed" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "Pi-hole not checked: no password: run 'ph login'" - assert_output --partial "- ph login" - - run env PATH="${BIN_SANDBOX}:${PATH}" PH_BIN=no-such-ph "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "Pi-hole not checked: ph not found" -} - -@test "firetv doctor checks the hosts a needs-root package's code looks up and blocks nothing unasked" { - setup_firetv_pihole - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "warning: com.amazon.ftvads.deeplinking needs root; Pi-hole does not block aax-us-east.amazon-adsystem.com" - assert_output --partial "left alone, the TV needs them: api.amazon.com" - assert_output --partial "warning: com.amazon.device.metrics needs root and has no hostnames in its code" - assert_output --partial "- firetv --watch 120 domains com.amazon.device.metrics" - assert_output --partial "- ph deny aax-us-east.amazon-adsystem.com" - refute_output --partial "never-looked-up" - # Only the allowed, non-kept host needs a fresh check; mads is blocked in the log. - run grep -- '--json check' "${PIHOLE_STATE}/calls.log" - assert_output "--json check aax-us-east.amazon-adsystem.com" - run grep -c -- '--json deny' "${PIHOLE_STATE}/calls.log" - assert_output "0" -} - -@test "firetv doctor blocks the unblocked hosts through ph when told yes, then passes" { - setup_firetv_pihole - run bash -c 'printf "1\n" | env PATH="$1" DOTFILES_PROMPT=read "$2" doctor' _ "${BIN_SANDBOX}:${PATH}" "${FIRETV}" - assert_success - assert_output --partial "Pi-hole now blocks aax-us-east.amazon-adsystem.com" - run cat "${PIHOLE_STATE}/denied" - assert_output "aax-us-east.amazon-adsystem.com" - - # The log still says allowed; the fresh check sees the new block. - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "ok: com.amazon.ftvads.deeplinking needs root; Pi-hole blocks what the TV looks up from its code: mads.amazon.com aax-us-east.amazon-adsystem.com" - refute_output --partial "- ph deny" -} - -@test "firetv doctor carries on when a needs-root package's code cannot be read" { - setup_firetv_pihole - printf '%s\n' com.amazon.tv.acr > "${FIRETV_STATE_DIR}/needs-root" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "warning: com.amazon.tv.acr needs root and has no hostnames in its code" - assert_output --partial "Passed: " -} - -@test "firetv doctor fails when the Pi-hole denies a host the TV needs" { - setup_firetv_pihole - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "ok: Pi-hole denies none of the hosts the TV needs" - - printf '%s\n' ktpx.amazon.com >> "${PIHOLE_STATE}/denylist" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_failure - assert_output --partial "failed: Pi-hole denies hosts the TV needs: ktpx.amazon.com" - assert_output --partial "- ph undeny ktpx.amazon.com" -} - -@test "firetv doctor fails when ph cannot add the domains" { - setup_firetv_pihole - sed -i.bak 's/"errors":\[\]/"errors":[{"item":"aax.amazon-adsystem.com","error":"UNIQUE constraint failed"}]/' "${PH_BIN}" - run bash -c 'printf "1\n" | env PATH="$1" DOTFILES_PROMPT=read "$2" doctor' _ "${BIN_SANDBOX}:${PATH}" "${FIRETV}" - assert_failure - assert_output --partial "Pi-hole did not block them: aax.amazon-adsystem.com: UNIQUE constraint failed" -} - -@test "firetv domains needs one plain package name" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" domains - assert_failure - assert_output --partial "domains takes one package" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" domains 'com.x;reboot' - assert_failure - assert_output --partial "Not a package name" - [[ ! -e "${FIRETV_STATE}/calls.log" ]] - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch soon domains com.amazon.venezia - assert_failure - assert_output --partial "--watch needs a number of seconds" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 0 domains com.amazon.nope - assert_failure - assert_output --partial "com.amazon.nope is not on this TV" -} - -@test "firetv domains lists the built-in hosts the TV looked up and names its connections" { - setup_firetv_pihole - cat > "${BIN_SANDBOX}/dig" <<'MOCK' -#!/usr/bin/env bash -case "${*: -1}" in - avs-alexa-19-na.amazon.com) printf 'avs-lb.amazon.com.\n54.167.177.211\n' ;; - appstore-tv-prod-na.amazon.com) printf '52.1.2.3\n' ;; -esac -MOCK - chmod +x "${BIN_SANDBOX}/dig" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 1 domains com.amazon.venezia < /dev/null - assert_success - assert_output --partial "com.amazon.venezia (app ID 10171)" - assert_output --partial "looked up by the TV (2 of 4)" - assert_line --regexp '^ appstore-tv-prod-na\.amazon\.com +allowed +2 lookups$' - assert_line --regexp '^ unagi-na\.amazon\.com +blocked +1 lookups$' - assert_output --partial "2 more never looked up; --all lists them" - refute_output --partial "com.amazon.dev" - refute_output --partial "eu-west-1" - assert_line --regexp '^ 54\.167\.177\.211:443 +avs-alexa-19-na\.amazon\.com$' - refute_output --partial "127.0.0.1" - run grep -c -- '--json check' "${PIHOLE_STATE}/calls.log" - assert_output "0" - - run env PATH="${BIN_SANDBOX}:${PATH}" PH_LIMIT_ONCE=1 "${FIRETV}" --all --watch 0 domains com.amazon.venezia < /dev/null - assert_success - assert_output --partial "Built in, never looked up" - assert_line --regexp '^ ec2\.us-east-1\.amazonaws\.com +not blocked$' - assert_line --regexp '^ juggler\.amazon\.com +not blocked$' - refute_output --regexp ' \?$' - refute_output --partial "Connections in" -} - -@test "firetv domains lists built-in hosts plainly without a Pi-hole log" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 0 domains com.amazon.venezia < /dev/null - assert_success - assert_output --partial "No Pi-hole log (ph login?)" - assert_line " appstore-tv-prod-na.amazon.com" - assert_line " juggler.amazon.com" -} - -@test "firetv animations shows, speeds up, and restores Android's animation scales" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations - assert_success - assert_line --regexp '^ animator_duration_scale +1 \(default\)$' - run grep -c 'settings put' "${FIRETV_STATE}/calls.log" - assert_output "0" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations fast - assert_success - assert_line --regexp '^ window_animation_scale +0\.5$' - assert_line --regexp '^ transition_animation_scale +0\.5$' - assert_line --regexp '^ animator_duration_scale +0\.5$' - assert_output --partial "Animations set to fast (0.5x)" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations normal - assert_success - assert_line --regexp '^ window_animation_scale +1$' - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" animations ludicrous - assert_failure - assert_output --partial "animations takes fast or normal" -} - -@test "firetv tweak saves what it replaces and off restores it, unset included" { - setup_firetv_sandbox - printf '%s\n' 'global wifi_scan_always_enabled 1' > "${FIRETV_STATE}/settings" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweaks - assert_success - assert_line --regexp '^ no-wifi-scan +off +No Wi-Fi scanning' - assert_line --regexp '^ fast-animations +off +Animations at 0\.5x$' - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-wifi-scan on - assert_success - assert_output --partial "global wifi_scan_always_enabled: 1 -> 0" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan on - assert_success - assert_output --partial "global ble_scan_always_enabled: null -> 0" - # A second on keeps the first saved value. - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-wifi-scan on - assert_success - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweaks - assert_line --regexp '^ no-wifi-scan +on ' - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-wifi-scan off - assert_success - assert_output --partial "global wifi_scan_always_enabled: 0 -> 1 (restored)" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan off - assert_success - run grep -c 'settings delete global ble_scan_always_enabled' "${FIRETV_STATE}/calls.log" - assert_output "1" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan off - assert_success - assert_output --partial "no saved value; left at null" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak warp-speed on - assert_failure - assert_output --partial "Unknown tweak: warp-speed" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak no-ble-scan maybe - assert_failure - assert_output --partial "Use: firetv tweak NAME on|off" -} - -@test "firetv tweaks reads your tweaks file and skips unsafe lines" { - setup_firetv_sandbox - export FIRETV_TWEAKS_FILE="${TEST_TMPDIR}/tweaks" - printf '%s\n' '# my tweaks' $'quiet-home\tsecure\tftv_autoplay\t0\tNo autoplay on Home' \ - $'evil\tglobal\tx\t1;reboot\tNope' $'bad-ns\tvendor\tx\t1\tNope' > "${FIRETV_TWEAKS_FILE}" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweaks - assert_success - assert_line --regexp '^ quiet-home +off +No autoplay on Home$' - assert_output --partial "Skipping tweak line: evil global x 1;reboot" - assert_output --partial "Skipping tweak line: bad-ns vendor" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" tweak quiet-home on - assert_success - run grep -c 'settings put secure ftv_autoplay 0' "${FIRETV_STATE}/calls.log" - assert_output "1" - run grep -c reboot "${FIRETV_STATE}/calls.log" - assert_output "0" -} - -@test "firetv settings-diff shows what changed between two runs and suggests tweak lines" { - setup_firetv_sandbox - printf '%s\n' 'secure ftv_autoplay 1' 'global stay_on 0' 'system font_scale 1.0' > "${FIRETV_STATE}/settings" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff - assert_success - assert_output --partial "Saved 3 settings" - assert_output --partial "run firetv settings-diff again" - - printf '%s\n' 'secure ftv_autoplay 0' 'global new_key on' 'system font_scale -' >> "${FIRETV_STATE}/settings" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff - assert_success - assert_line --regexp '^ changed +secure +ftv_autoplay +1 -> 0$' - assert_line --regexp '^ added +global +new_key +\(unset\) -> on$' - assert_line --regexp '^ removed +system +font_scale +1\.0 -> \(unset\)$' - assert_line "$(printf 'my-tweak\tsecure\tftv_autoplay\t0\tDescribe it')" - refute_output --partial $'my-tweak\tsystem' - [[ ! -e "${FIRETV_STATE_DIR}/settings-snapshot" ]] - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff - assert_success - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" settings-diff reset - assert_success - [[ ! -e "${FIRETV_STATE_DIR}/settings-snapshot" ]] -} - -@test "firetv clean trims caches, and doctor warns when storage is low" { - setup_firetv_sandbox - printf '%s\n' 512000 > "${FIRETV_STATE}/free" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "warning: Storage low: 500 MB free" - assert_output --partial "- firetv clean" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" clean - assert_success - assert_output --partial "App caches trimmed: 200 MB freed, 700 MB free" - - printf '%s\n' 6892824 > "${FIRETV_STATE}/free" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_output --partial "ok: Storage: 6731 MB free" -} - -@test "firetv apps lists installed apps without system overlays, and install sideloads an APK" { - setup_firetv_sandbox - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" apps - assert_success - assert_line --regexp '^ com\.netflix\.ninja +27207$' - refute_output --partial "overlay" - - printf 'not really an apk' > "${TEST_TMPDIR}/org.example.clock.apk" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/org.example.clock.apk" - assert_success - assert_output --partial "Installed org.example.clock.apk" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" apps - assert_line --regexp '^ org\.example\.clock +1$' - - printf 'x' > "${TEST_TMPDIR}/broken.apk" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/broken.apk" - assert_failure - assert_output --partial "Install failed: Failure [INSTALL_PARSE_FAILED_NOT_APK]" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/missing.apk" - assert_failure - assert_output --partial "No such file" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" install "${TEST_TMPDIR}/notes.txt" - assert_failure - assert_output --partial "Not an APK" -} - -@test "firetv uninstall removes only your own apps, and only when told yes" { - setup_firetv_sandbox - printf '%s\n' org.example.clock >> "${FIRETV_STATE}/installed" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall org.example.clock < /dev/null - assert_success - assert_output --partial "org.example.clock kept" - run grep -c ' uninstall org.example.clock$' "${FIRETV_STATE}/calls.log" - assert_output "0" - - run bash -c 'printf "1\n" | env PATH="$1" DOTFILES_PROMPT=read "$2" uninstall org.example.clock' _ "${BIN_SANDBOX}:${PATH}" "${FIRETV}" - assert_success - assert_output --partial "Uninstalled org.example.clock" - run grep -cxF org.example.clock "${FIRETV_STATE}/installed" - assert_output "0" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall com.amazon.tv.launcher < /dev/null - assert_failure - assert_output --partial "core package the TV needs" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall com.amazon.tv.settings.v2.overlay.localisation.common < /dev/null - assert_failure - assert_output --partial "system resource overlay" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall com.amazon.tv.acr < /dev/null - assert_failure - assert_output --partial "built in and can't be uninstalled; firetv disable com.amazon.tv.acr" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall org.example.gone < /dev/null - assert_failure - assert_output --partial "org.example.gone is not on this TV" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" uninstall 'x;reboot' < /dev/null - assert_failure - assert_output --partial "Not a package name" - run grep -c -E ' uninstall [^ ]+$' "${FIRETV_STATE}/calls.log" - assert_output "1" -} - -@test "firetv doctor trusts a clean watch of a needs-root package until its version changes" { - setup_firetv_pihole - printf '%s\n' com.amazon.device.metrics > "${FIRETV_STATE_DIR}/needs-root" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --partial "warning: com.amazon.device.metrics needs root and has no hostnames in its code; watch what it connects to" - - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 1 domains com.amazon.device.metrics < /dev/null - assert_success - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_success - assert_output --regexp "ok: com\.amazon\.device\.metrics needs root; no hostnames in its code, no connections in a 1s watch on [0-9]{4}-[0-9]{2}-[0-9]{2}" - refute_output --partial "domains com.amazon.device.metrics" - - printf '%s\n' 2 > "${FIRETV_STATE}/metrics-version" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_output --partial "warning: com.amazon.device.metrics needs root and changed version since the watch on" - assert_output --partial "- firetv --watch 120 domains com.amazon.device.metrics" - - touch "${FIRETV_STATE}/metrics-connects" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" --watch 1 domains com.amazon.device.metrics < /dev/null - assert_success - assert_output --partial "54.167.177.211:443" - run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" doctor < /dev/null - assert_output --partial "warning: com.amazon.device.metrics needs root and made connections in the watch on" - run grep -c com.amazon.device.metrics "${FIRETV_STATE_DIR}/watched" - assert_output "1" -} From db66cb419f063515f89d87fc6ba6cabcfcc22ffd Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:45:12 -0700 Subject: [PATCH 02/10] Read link.sh output through a file in df-doctor --- scripts/doctor.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 4775283..006c1d5 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -22,6 +22,7 @@ EXPLAIN="" REVIEW_INCOMPLETE=0 MANAGED_PATHS_CACHE="" LINK_STATUS="" +LINK_OUTPUT="" LOGIN_ENV="" XDG_DB="" XDG_PROGRAMS="" @@ -188,9 +189,22 @@ link_script() { bash "${BASEDIR}/scripts/link.sh" --repo "${SOURCE_DIR}" --destination "${DEST_DIR}" "$@" } +# link_script's output in LINK_OUTPUT, through a file: macOS bash 3.2 can fail +# a write to a pipe with EINTR when a child exits, which read as a failed check. +link_output() { + local file status=0 + file="$(mktemp)" || return 1 + link_script "$@" > "${file}" || status=$? + LINK_OUTPUT="$(< "${file}")" + rm -f "${file}" + return "${status}" +} + prime_managed_paths_cache() { [[ -z "${MANAGED_PATHS_CACHE}" ]] || return 0 - if ! MANAGED_PATHS_CACHE="$(link_script managed)"; then + if link_output managed; then + MANAGED_PATHS_CACHE="${LINK_OUTPUT}" + else log_error "Could not list managed paths; the check is incomplete" return 1 fi @@ -635,11 +649,12 @@ size_of() { collect_links() { local line state path - if ! LINK_STATUS="$(link_script status)"; then + if ! link_output status; then log_warn "Could not check the repo's links; the check is incomplete" REVIEW_INCOMPLETE=1 return fi + LINK_STATUS="${LINK_OUTPUT}" while IFS= read -r line; do [[ -n "${line}" ]] || continue state="${line%% *}" From d2657c600e0099f99811f546c2166579724c2e81 Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:45:16 -0700 Subject: [PATCH 03/10] Keep tests off the real home and a symlinked TMPDIR --- tests/bootstrap.bats | 3 +++ tests/githooks.bats | 4 +++- tests/install.bats | 7 +++++-- tests/lint_docs.bats | 4 +++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats index 1b529ce..55f2942 100644 --- a/tests/bootstrap.bats +++ b/tests/bootstrap.bats @@ -7,6 +7,9 @@ load test_helper setup() { setup_tmpdir + # A scratch home, so no script under test can reach the real one. + export HOME="${TEST_TMPDIR}/home" + mkdir -p "${HOME}" } teardown() { diff --git a/tests/githooks.bats b/tests/githooks.bats index cec469e..f4b49f2 100644 --- a/tests/githooks.bats +++ b/tests/githooks.bats @@ -8,6 +8,8 @@ setup() { setup_tmpdir mkdir -p "${TEST_TMPDIR}/home/.config/agents/scripts" "${TEST_TMPDIR}/bin" CHECK_DRIFT="${TEST_TMPDIR}/home/.config/agents/scripts/check-drift" + # git reads the scratch home's config, not the real one's signing or hooks. + export HOME="${TEST_TMPDIR}/home" # A scratch repo with the hook and the runner it lints through, so what # the real repo has staged never matters. REPO="${TEST_TMPDIR}/repo" @@ -27,7 +29,7 @@ write_check_drift() { } run_pre_commit() { - run env HOME="${TEST_TMPDIR}/home" bash -c 'cd "$1" && bash .githooks/pre-commit' _ "${REPO}" + run bash -c 'cd "$1" && bash .githooks/pre-commit' _ "${REPO}" } # A script ShellCheck flags (SC2086, an unquoted variable) and one it passes. diff --git a/tests/install.bats b/tests/install.bats index f4fe162..fce8465 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -706,6 +706,7 @@ run_resolve_harden() { export TEST_LOG="'"${TEST_TMPDIR}"'/actions.log" export TEST_STOW_STUB="'"${TEST_TMPDIR}"'/stow.stub" export AGENTS_REPO_URL="file://'"${TEST_TMPDIR}"'/no-agents-repo" + export HOMEBREW_PREFIX="'"${TEST_TMPDIR}"'/brew" export USER="sandbox-user" export SHELL="${TEST_BIN}/zsh" @@ -796,17 +797,19 @@ MOCK chmod +x "${TEST_BIN}/hostname" "${TEST_BIN}/find" "${TEST_BIN}/zsh" "${TEST_BIN}/grep" "${TEST_BIN}/ssh-keygen" "${TEST_BIN}/brew" "${TEST_STOW_STUB}" cd "'"${PROJECT_ROOT}"'" + # install.sh links the physical path, which a symlinked TMPDIR changes. + REAL_ROOT="$(pwd -P)" # No installs: claude-code, on by default, would run its real # installer; holeberry is off by default, and dropping it is harmless. ./install.sh --skip-system --skip-brew --no-install claude-code --no-install holeberry --adopt [[ -L "${HOME}/.dotfiles" ]] || { echo "missing dotfiles symlink"; exit 1; } [[ "$(cat "${HOME}/.local/state/dotfiles/installs")" == "" ]] || { echo "installs not saved as none"; exit 1; } - [[ "$(readlink "${HOME}/.dotfiles")" == "'"${PROJECT_ROOT}"'" ]] || { echo "bad dotfiles symlink"; exit 1; } + [[ "$(readlink "${HOME}/.dotfiles")" == "${REAL_ROOT}" ]] || { echo "bad dotfiles symlink"; exit 1; } [[ -f "${HOME}/.ssh/id_ed25519" ]] || { echo "missing private key"; exit 1; } [[ -f "${HOME}/.ssh/id_ed25519.pub" ]] || { echo "missing public key"; exit 1; } grep -qx "brew install stow" "${TEST_LOG}" || { echo "missing brew install stow"; exit 1; } - grep -q "^stow --dir '"${PROJECT_ROOT}"'/stow --target ${HOME} --no-folding --restow .* --adopt common\( \|$\)" "${TEST_LOG}" || { + grep -q "^stow --dir ${REAL_ROOT}/stow --target ${HOME} --no-folding --restow .* --adopt common\( \|$\)" "${TEST_LOG}" || { echo "missing stow run" cat "${TEST_LOG}" exit 1 diff --git a/tests/lint_docs.bats b/tests/lint_docs.bats index b50c141..f595e58 100644 --- a/tests/lint_docs.bats +++ b/tests/lint_docs.bats @@ -7,7 +7,9 @@ load test_helper setup() { setup_tmpdir REPO="${TEST_TMPDIR}/repo" - mkdir -p "${REPO}/scripts" "${REPO}/docs" "${REPO}/stow/tools/.local/bin" + # git reads the scratch home's config, not the real one's excludes. + export HOME="${TEST_TMPDIR}/home" + mkdir -p "${HOME}" "${REPO}/scripts" "${REPO}/docs" "${REPO}/stow/tools/.local/bin" git -C "${REPO}" init -q printf '#!/usr/bin/env bash\n# Does one thing.\nset -euo pipefail\n[[ "${1:-}" == --dry-run ]] && echo "${SCRATCH_HOME:-}"\n' > "${REPO}/scripts/tool.sh" printf '# Repo\n\nRun `scripts/tool.sh` with `--dry-run`; `SCRATCH_HOME` moves its state.\n' > "${REPO}/README.md" From 10c202179169374aa9e104cb3d7e0d12ccd003b3 Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:45:18 -0700 Subject: [PATCH 04/10] Build test fixtures once and drop sleeps and slow steps --- tests/ai_doctor.bats | 17 ++++++++---- tests/deps.bats | 66 +++++++++++++++++++++++++------------------- tests/doctor.bats | 2 +- tests/install.bats | 8 ++++-- 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/tests/ai_doctor.bats b/tests/ai_doctor.bats index fdf4432..8e28923 100644 --- a/tests/ai_doctor.bats +++ b/tests/ai_doctor.bats @@ -8,12 +8,20 @@ load test_helper AI_DOCTOR="${PROJECT_ROOT}/stow/tools/.local/bin/ai-doctor" -setup() { - setup_tmpdir - local python uv +# Checked once, with the transcript every test starts from. +setup_file() { + local python python="$(command -v python3 || true)" [[ -n "${python}" ]] || skip "python3 is required" "${python}" -c 'import sys; sys.exit(sys.version_info < (3, 11))' || skip "python3 3.11+ is required" + HOME="${BATS_FILE_TMPDIR}/home" used commit-work 1 + HOME="${BATS_FILE_TMPDIR}/home" used obsidian-vault 2 +} + +setup() { + setup_tmpdir + local python uv + python="$(command -v python3)" uv="$(command -v uv || true)" [[ -n "${uv}" ]] || skip "uv is required" # Only uv and python3 on PATH, and AI_DOCTOR_CLAUDE and AI_DOCTOR_CODEX @@ -38,8 +46,7 @@ setup() { ln -s "${HOME}/.agents/skills" "${HOME}/.claude/skills" add_skill core commit-work add_skill vault obsidian-vault - used commit-work 1 - used obsidian-vault 2 + cp -R "${BATS_FILE_TMPDIR}/home/.claude/projects" "${HOME}/.claude/" # skills-add from the agents checkout, logging what it was asked to do. mkdir -p "${XDG_CONFIG_HOME}/agents/scripts" diff --git a/tests/deps.bats b/tests/deps.bats index e01adb6..0426fdf 100644 --- a/tests/deps.bats +++ b/tests/deps.bats @@ -4,6 +4,35 @@ load test_helper +# Fixture tarballs, named as upstream names them, built once; each test gets +# its own copy, which one test trims. +setup_file() { + local stage="${BATS_FILE_TMPDIR}/stage" fixtures="${BATS_FILE_TMPDIR}/fixtures" + mkdir -p "${fixtures}" "${stage}/bats-core-1.14.0/bin" "${stage}/bats-core-1.14.0/lib/bats-core" \ + "${stage}/bats-support-0.3.0" "${stage}/bats-assert-2.2.4" \ + "${stage}/parallel-20260922/src" "${stage}/bash-5.3" + printf '#!/bin/sh\necho "Bats stub"\n' > "${stage}/bats-core-1.14.0/bin/bats" + chmod +x "${stage}/bats-core-1.14.0/bin/bats" + printf 'bats_support_loaded=1\n' > "${stage}/bats-support-0.3.0/load.bash" + printf 'bats_assert_loaded=1\n' > "${stage}/bats-assert-2.2.4/load.bash" + printf '#!/bin/sh\necho "GNU parallel stub"\n' > "${stage}/parallel-20260922/src/parallel" + printf '#!/bin/sh\nexit 0\n' > "${stage}/bash-5.3/configure" + chmod +x "${stage}/bash-5.3/configure" + tar -czf "${fixtures}/v1.14.0.tar.gz" -C "${stage}" bats-core-1.14.0 + tar -czf "${fixtures}/v0.3.0.tar.gz" -C "${stage}" bats-support-0.3.0 + tar -czf "${fixtures}/v2.2.4.tar.gz" -C "${stage}" bats-assert-2.2.4 + tar -cjf "${fixtures}/parallel-20260922.tar.bz2" -C "${stage}" parallel-20260922 + tar -czf "${fixtures}/bash-5.3.tar.gz" -C "${stage}" bash-5.3 + + # The pins match the fixtures, not upstream. + export DEPS_BATS_CORE_SHA256 DEPS_BATS_SUPPORT_SHA256 DEPS_BATS_ASSERT_SHA256 DEPS_PARALLEL_SHA256 DEPS_BASH_SHA256 + DEPS_BATS_CORE_SHA256="$(shasum -a 256 "${fixtures}/v1.14.0.tar.gz" | cut -d' ' -f1)" + DEPS_BATS_SUPPORT_SHA256="$(shasum -a 256 "${fixtures}/v0.3.0.tar.gz" | cut -d' ' -f1)" + DEPS_BATS_ASSERT_SHA256="$(shasum -a 256 "${fixtures}/v2.2.4.tar.gz" | cut -d' ' -f1)" + DEPS_PARALLEL_SHA256="$(shasum -a 256 "${fixtures}/parallel-20260922.tar.bz2" | cut -d' ' -f1)" + DEPS_BASH_SHA256="$(shasum -a 256 "${fixtures}/bash-5.3.tar.gz" | cut -d' ' -f1)" +} + setup() { setup_tmpdir export HOME="${TEST_TMPDIR}/home" @@ -15,7 +44,8 @@ setup() { unset DEPS_DIR DEPS_TEST_BASH DEPS_BATS BATS_LIB_PATH_SAVED FIXTURES="${TEST_TMPDIR}/fixtures" CURL_LOG="${TEST_TMPDIR}/curl.log" - mkdir -p "${HOME}" "${FIXTURES}" "${TEST_TMPDIR}/bin" + mkdir -p "${HOME}" "${TEST_TMPDIR}/bin" + cp -R "${BATS_FILE_TMPDIR}/fixtures" "${FIXTURES}" # Only the stubs, plus the system tools deps.sh needs (tar, mktemp, shasum). export PATH="${TEST_TMPDIR}/bin:/usr/bin:/bin" @@ -37,32 +67,6 @@ cp "${FIXTURES}/\${url##*/}" "\$dest" CURL chmod +x "${TEST_TMPDIR}/bin/curl" - # Fixture tarballs, named as upstream names them. - local stage="${TEST_TMPDIR}/stage" - mkdir -p "${stage}/bats-core-1.14.0/bin" "${stage}/bats-core-1.14.0/lib/bats-core" \ - "${stage}/bats-support-0.3.0" "${stage}/bats-assert-2.2.4" \ - "${stage}/parallel-20260922/src" "${stage}/bash-5.3" - printf '#!/bin/sh\necho "Bats stub"\n' > "${stage}/bats-core-1.14.0/bin/bats" - chmod +x "${stage}/bats-core-1.14.0/bin/bats" - printf 'bats_support_loaded=1\n' > "${stage}/bats-support-0.3.0/load.bash" - printf 'bats_assert_loaded=1\n' > "${stage}/bats-assert-2.2.4/load.bash" - printf '#!/bin/sh\necho "GNU parallel stub"\n' > "${stage}/parallel-20260922/src/parallel" - printf '#!/bin/sh\nexit 0\n' > "${stage}/bash-5.3/configure" - chmod +x "${stage}/bash-5.3/configure" - tar -czf "${FIXTURES}/v1.14.0.tar.gz" -C "${stage}" bats-core-1.14.0 - tar -czf "${FIXTURES}/v0.3.0.tar.gz" -C "${stage}" bats-support-0.3.0 - tar -czf "${FIXTURES}/v2.2.4.tar.gz" -C "${stage}" bats-assert-2.2.4 - tar -cjf "${FIXTURES}/parallel-20260922.tar.bz2" -C "${stage}" parallel-20260922 - tar -czf "${FIXTURES}/bash-5.3.tar.gz" -C "${stage}" bash-5.3 - - # The pins match the fixtures, not upstream. - export DEPS_BATS_CORE_SHA256 DEPS_BATS_SUPPORT_SHA256 DEPS_BATS_ASSERT_SHA256 DEPS_PARALLEL_SHA256 DEPS_BASH_SHA256 - DEPS_BATS_CORE_SHA256="$(shasum -a 256 "${FIXTURES}/v1.14.0.tar.gz" | cut -d' ' -f1)" - DEPS_BATS_SUPPORT_SHA256="$(shasum -a 256 "${FIXTURES}/v0.3.0.tar.gz" | cut -d' ' -f1)" - DEPS_BATS_ASSERT_SHA256="$(shasum -a 256 "${FIXTURES}/v2.2.4.tar.gz" | cut -d' ' -f1)" - DEPS_PARALLEL_SHA256="$(shasum -a 256 "${FIXTURES}/parallel-20260922.tar.bz2" | cut -d' ' -f1)" - DEPS_BASH_SHA256="$(shasum -a 256 "${FIXTURES}/bash-5.3.tar.gz" | cut -d' ' -f1)" - # In-process, so a test can override a function; the real BATS_LIB_PATH is # restored because deps_ensure sets its own. BATS_LIB_PATH_SAVED="${BATS_LIB_PATH}" @@ -266,8 +270,14 @@ UV # run_tests.sh's preflight: the machine's own tools are required, not installed, # and --lint-only touches neither the network nor the cache. +# A copy of the runner, from the repo root, with a stub docs lint: +# lint_docs.bats covers that, and the real one takes seconds. runner() { - (cd "${PROJECT_ROOT}" && env PATH="${TEST_TMPDIR}/bin:/usr/bin:/bin" bash tests/run_tests.sh "$@") + local tests="${TEST_TMPDIR}/runner/tests" + mkdir -p "${tests}" + cp "${PROJECT_ROOT}/tests/run_tests.sh" "${PROJECT_ROOT}/tests/deps.sh" "${tests}/" + printf '#!/bin/sh\n' > "${tests}/lint-docs.sh" + (cd "${PROJECT_ROOT}" && env PATH="${TEST_TMPDIR}/bin:/usr/bin:/bin" bash "${tests}/run_tests.sh" "$@") } @test "runner: a missing shellcheck fails with the install hint and installs nothing" { diff --git a/tests/doctor.bats b/tests/doctor.bats index ce3d846..c996c35 100644 --- a/tests/doctor.bats +++ b/tests/doctor.bats @@ -769,7 +769,7 @@ JSON printf 'b\n' > "${HOME}/.old-b" # Rows: .notes.bak (archive), .old-a, .old-b, ph-extra. Set the first to # archive, then back to skip; ignore the second; delete the third after a yes. - run bash -c '{ printf "as"; sleep 1; printf "\033[Bi"; sleep 1; printf "\033[Bd"; sleep 1; printf "\r"; sleep 1; printf "y\r"; sleep 1; } | + run bash -c 'printf "as\033[Bi\033[Bd\ry\r" | script -q /dev/null bash "$1" --source "$2"' _ "${DOCTOR}" "${TEST_SOURCE_DIR}" assert_success assert_output --partial "Choose a change for each item" diff --git a/tests/install.bats b/tests/install.bats index fce8465..35c2b59 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -400,7 +400,9 @@ run_resolve_harden() { local repo="${TEST_TMPDIR}/repo" home="${TEST_TMPDIR}/harden-home" bin="${TEST_TMPDIR}/bin" mkdir -p "${repo}/scripts/bootstrap/linux" "${repo}/stow/common" "${repo}/personas" "${repo}/roles" "${home}" "${bin}" cp "${PROJECT_ROOT}/install.sh" "${repo}/" - cp "${PROJECT_ROOT}/scripts/common.sh" "${PROJECT_ROOT}/scripts/link.sh" "${PROJECT_ROOT}/scripts/doctor.sh" "${repo}/scripts/" + cp "${PROJECT_ROOT}/scripts/common.sh" "${PROJECT_ROOT}/scripts/link.sh" "${repo}/scripts/" + # A stub: the isolated-home test covers the file review, a second a run. + printf '#!/usr/bin/env bash\n' > "${repo}/scripts/doctor.sh" printf 'export DEMO=1\n' > "${repo}/stow/common/.demo" printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nharden=yes\nbrew=\nbrew_optional=\n' > "${repo}/personas/server" cp "${repo}/personas/server" "${repo}/personas/home" @@ -949,7 +951,9 @@ MOCK local repo="${TEST_TMPDIR}/repo" home="${TEST_TMPDIR}/install-home" bin="${TEST_TMPDIR}/bin" mkdir -p "${repo}/scripts/bootstrap/linux" "${repo}/stow/common" "${repo}/personas" "${repo}/roles" "${home}" "${bin}" cp "${PROJECT_ROOT}/install.sh" "${repo}/" - cp "${PROJECT_ROOT}/scripts/common.sh" "${PROJECT_ROOT}/scripts/link.sh" "${PROJECT_ROOT}/scripts/doctor.sh" "${repo}/scripts/" + cp "${PROJECT_ROOT}/scripts/common.sh" "${PROJECT_ROOT}/scripts/link.sh" "${repo}/scripts/" + # A stub: the isolated-home test covers the file review, a second a run. + printf '#!/usr/bin/env bash\n' > "${repo}/scripts/doctor.sh" printf 'export DEMO=1\n' > "${repo}/stow/common/.demo" printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nbrew=\nbrew_optional=\nroles_optional=demo\n' > "${repo}/personas/home" cp "${repo}/personas/home" "${repo}/personas/server" From d606faf39297cbc8da91bc7c5d24e2452422b153 Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:52:30 -0700 Subject: [PATCH 05/10] Write the firetv and ph-backup mocks once per file --- tests/bin_firetv.bats | 145 +++++++++++++++++++++------------------ tests/bin_ph_backup.bats | 69 +++++++++++-------- 2 files changed, 118 insertions(+), 96 deletions(-) diff --git a/tests/bin_firetv.bats b/tests/bin_firetv.bats index 7095ec3..d1bdfe6 100644 --- a/tests/bin_firetv.bats +++ b/tests/bin_firetv.bats @@ -15,22 +15,14 @@ teardown() { teardown_tmpdir } -setup_firetv_sandbox() { - FIRETV="${PROJECT_ROOT}/stow/personal/.local/bin/firetv" - export FIRETV_STATE="${TEST_TMPDIR}/tv" - export FIRETV_STATE_DIR="${TEST_TMPDIR}/state" - # Never the real Pi-hole: this ph refuses until setup_firetv_pihole replaces it. - export PH_BIN="${BIN_SANDBOX}/ph" - export FIRETV_TWEAKS_FILE="${TEST_TMPDIR}/no-tweaks" +# The mocks are written once: macOS checks each new executable on its first +# run, which would cost every test. Tests link them and never write through. +setup_file() { + export FIRETV_MOCKS="${BATS_FILE_TMPDIR}/mocks" + mkdir -p "${FIRETV_MOCKS}" printf '%s\n' '#!/usr/bin/env bash' \ - "printf 'ph: no password: run '\\''ph login'\\'' or set PIHOLE_PASSWORD\\n' >&2" 'exit 1' > "${PH_BIN}" - chmod +x "${PH_BIN}" - mkdir -p "${FIRETV_STATE}" - # The TV: installed packages and disabled ones, one per line. - printf '%s\n' com.amazon.tv.acr com.amazon.device.metrics com.amazon.device.metrics \ - com.amazon.storm.lightning.tutorial com.amazon.tv.launcher > "${FIRETV_STATE}/installed" - printf '%s\n' com.amazon.storm.lightning.tutorial > "${FIRETV_STATE}/disabled" - cat > "${BIN_SANDBOX}/adb" <<'MOCK' + "printf 'ph: no password: run '\\''ph login'\\'' or set PIHOLE_PASSWORD\\n' >&2" 'exit 1' > "${FIRETV_MOCKS}/ph-no-login" + cat > "${FIRETV_MOCKS}/adb" <<'MOCK' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >> "${FIRETV_STATE}/calls.log" @@ -109,7 +101,70 @@ case "$*" in *) printf 'adb %s\n' "$*" ;; esac MOCK - chmod +x "${BIN_SANDBOX}/adb" + # ph's --json check and deny, against a deny list in a file. + cat > "${FIRETV_MOCKS}/ph-pihole" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${PIHOLE_STATE}/calls.log" +[[ "${1:-}" == --json ]] || { printf 'ph: test expects --json\n' >&2; exit 1; } +shift +case "$1" in + -n) + [[ "$3" == queries && "$4" == 192.168.0.84 ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } + jq -n '{queries: [ + {domain: "appstore-tv-prod-na.amazon.com", status: "FORWARDED"}, + {domain: "appstore-tv-prod-na.amazon.com", status: "CACHE"}, + {domain: "unagi-na.amazon.com", status: "GRAVITY"}, + {domain: "avs-alexa-19-na.amazon.com", status: "FORWARDED"}, + {domain: "mads.amazon.com", status: "GRAVITY"}, + {domain: "aax-us-east.amazon-adsystem.com", status: "FORWARDED"}, + {domain: "api.amazon.com", status: "CACHE"}, + {domain: "bad name;rm", status: "FORWARDED"}]}' + ;; + check) + # A Pi-hole that rate-limits the first login for a domain. + if [[ -n "${PH_LIMIT_ONCE:-}" && ! -e "${PIHOLE_STATE}/limited-$2" ]]; then + touch "${PIHOLE_STATE}/limited-$2" + printf 'ph: login failed: HTTP 429: Rate-limiting login attempts\n' >&2 + exit 1 + fi + if grep -qxF "$2" "${PIHOLE_STATE}/blocked"; then + printf '{"search":{"domains":[],"gravity":[{"domain":"%s","type":"block","enabled":true}]}}\n' "$2" + else + printf '{"search":{"domains":[],"gravity":[]}}\n' + fi + ;; + list) + [[ "$2" == deny ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } + jq -R -s '{domains: (split("\n") | map(select(. != "")) | map({domain: ., kind: "exact", type: "deny", enabled: true}))}' \ + "${PIHOLE_STATE}/denylist" + ;; + deny) + [[ "$2" == -c && -n "$3" ]] || { printf 'ph: test expects a comment\n' >&2; exit 1; } + shift 3 + printf '%s\n' "$@" | tee -a "${PIHOLE_STATE}/blocked" > "${PIHOLE_STATE}/denied" + printf '{"processed":{"success":[],"errors":[]}}\n' + ;; + *) printf 'ph: unexpected %s\n' "$*" >&2; exit 1 ;; +esac +MOCK + chmod +x "${FIRETV_MOCKS}"/* +} + +setup_firetv_sandbox() { + FIRETV="${PROJECT_ROOT}/stow/personal/.local/bin/firetv" + export FIRETV_STATE="${TEST_TMPDIR}/tv" + export FIRETV_STATE_DIR="${TEST_TMPDIR}/state" + # Never the real Pi-hole: this ph refuses until setup_firetv_pihole replaces it. + export PH_BIN="${BIN_SANDBOX}/ph" + export FIRETV_TWEAKS_FILE="${TEST_TMPDIR}/no-tweaks" + ln -s "${FIRETV_MOCKS}/ph-no-login" "${PH_BIN}" + mkdir -p "${FIRETV_STATE}" + # The TV: installed packages and disabled ones, one per line. + printf '%s\n' com.amazon.tv.acr com.amazon.device.metrics com.amazon.device.metrics \ + com.amazon.storm.lightning.tutorial com.amazon.tv.launcher > "${FIRETV_STATE}/installed" + printf '%s\n' com.amazon.storm.lightning.tutorial > "${FIRETV_STATE}/disabled" + ln -s "${FIRETV_MOCKS}/adb" "${BIN_SANDBOX}/adb" } @test "firetv shows help and rejects bad commands and groups" { @@ -222,7 +277,9 @@ MOCK printf '%s\n' com.amazon.gamehub >> "${FIRETV_STATE}/installed" run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat assert_success - sed -i.bak 's/new state: enabled/Unknown package/' "${BIN_SANDBOX}/adb" + rm "${BIN_SANDBOX}/adb" + sed 's/new state: enabled/Unknown package/' "${FIRETV_MOCKS}/adb" > "${BIN_SANDBOX}/adb" + chmod +x "${BIN_SANDBOX}/adb" printf '%s\n' com.amazon.gamehub >> "${FIRETV_STATE}/disabled" run env PATH="${BIN_SANDBOX}:${PATH}" "${FIRETV}" enable bloat assert_failure @@ -335,54 +392,7 @@ setup_firetv_pihole() { printf '%s\n' mads.amazon.com > "${PIHOLE_STATE}/blocked" printf '%s\n' msh.amazon.com > "${PIHOLE_STATE}/denylist" : > "${PIHOLE_STATE}/calls.log" - # ph's --json check and deny, against a deny list in a file. - cat > "${PH_BIN}" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> "${PIHOLE_STATE}/calls.log" -[[ "${1:-}" == --json ]] || { printf 'ph: test expects --json\n' >&2; exit 1; } -shift -case "$1" in - -n) - [[ "$3" == queries && "$4" == 192.168.0.84 ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } - jq -n '{queries: [ - {domain: "appstore-tv-prod-na.amazon.com", status: "FORWARDED"}, - {domain: "appstore-tv-prod-na.amazon.com", status: "CACHE"}, - {domain: "unagi-na.amazon.com", status: "GRAVITY"}, - {domain: "avs-alexa-19-na.amazon.com", status: "FORWARDED"}, - {domain: "mads.amazon.com", status: "GRAVITY"}, - {domain: "aax-us-east.amazon-adsystem.com", status: "FORWARDED"}, - {domain: "api.amazon.com", status: "CACHE"}, - {domain: "bad name;rm", status: "FORWARDED"}]}' - ;; - check) - # A Pi-hole that rate-limits the first login for a domain. - if [[ -n "${PH_LIMIT_ONCE:-}" && ! -e "${PIHOLE_STATE}/limited-$2" ]]; then - touch "${PIHOLE_STATE}/limited-$2" - printf 'ph: login failed: HTTP 429: Rate-limiting login attempts\n' >&2 - exit 1 - fi - if grep -qxF "$2" "${PIHOLE_STATE}/blocked"; then - printf '{"search":{"domains":[],"gravity":[{"domain":"%s","type":"block","enabled":true}]}}\n' "$2" - else - printf '{"search":{"domains":[],"gravity":[]}}\n' - fi - ;; - list) - [[ "$2" == deny ]] || { printf 'ph: unexpected %s\n' "$*" >&2; exit 1; } - jq -R -s '{domains: (split("\n") | map(select(. != "")) | map({domain: ., kind: "exact", type: "deny", enabled: true}))}' \ - "${PIHOLE_STATE}/denylist" - ;; - deny) - [[ "$2" == -c && -n "$3" ]] || { printf 'ph: test expects a comment\n' >&2; exit 1; } - shift 3 - printf '%s\n' "$@" | tee -a "${PIHOLE_STATE}/blocked" > "${PIHOLE_STATE}/denied" - printf '{"processed":{"success":[],"errors":[]}}\n' - ;; - *) printf 'ph: unexpected %s\n' "$*" >&2; exit 1 ;; -esac -MOCK - chmod +x "${PH_BIN}" + ln -sf "${FIRETV_MOCKS}/ph-pihole" "${PH_BIN}" } @test "firetv doctor passes on ph's error and says to log in" { @@ -456,7 +466,10 @@ MOCK @test "firetv doctor fails when ph cannot add the domains" { setup_firetv_pihole - sed -i.bak 's/"errors":\[\]/"errors":[{"item":"aax.amazon-adsystem.com","error":"UNIQUE constraint failed"}]/' "${PH_BIN}" + rm "${PH_BIN}" + sed 's/"errors":\[\]/"errors":[{"item":"aax.amazon-adsystem.com","error":"UNIQUE constraint failed"}]/' \ + "${FIRETV_MOCKS}/ph-pihole" > "${PH_BIN}" + chmod +x "${PH_BIN}" run bash -c 'printf "1\n" | env PATH="$1" DOTFILES_PROMPT=read "$2" doctor' _ "${BIN_SANDBOX}:${PATH}" "${FIRETV}" assert_failure assert_output --partial "Pi-hole did not block them: aax.amazon-adsystem.com: UNIQUE constraint failed" diff --git a/tests/bin_ph_backup.bats b/tests/bin_ph_backup.bats index a17f837..f81d9ad 100644 --- a/tests/bin_ph_backup.bats +++ b/tests/bin_ph_backup.bats @@ -63,14 +63,16 @@ MOCK # ph-backup must run as root, so tests exercise a copy with the self-elevation # block stripped out. Mocks stand in for Pi-hole, Unbound, Tailscale, and the # GNU flags (stat -c, numfmt) that the Linux host provides but macOS does not. -setup_ph_backup_sandbox() { - local teleporter_status="${1:-0}" +# Both are written once, the mocks to $1: macOS checks a new executable on its first run. +write_ph_backup_mocks() { + local dir="$1" + mkdir -p "${dir}" - cat > "${BIN_SANDBOX}/pihole-FTL" < "${dir}/pihole-FTL" <<'MOCK' #!/usr/bin/env bash -case "\${1}" in +case "${1}" in --teleporter) - [[ "${teleporter_status}" -eq 0 ]] || exit 1 + [[ "${PH_BACKUP_TELEPORTER_STATUS:-0}" -eq 0 ]] || exit 1 # A real zip, so the restore map can enumerate its paths the way it # does against Pi-hole's own export. mkdir -p etc/pihole @@ -82,7 +84,7 @@ case "\${1}" in ;; --config) printf '["127.0.0.1#5335"]\n' ;; sqlite3) - case "\${3}" in + case "${3}" in *gravity*) printf '8843595\n' ;; *) printf '9\n' ;; esac @@ -90,13 +92,13 @@ case "\${1}" in esac MOCK - cat > "${BIN_SANDBOX}/pihole" <<'MOCK' + cat > "${dir}/pihole" <<'MOCK' #!/usr/bin/env bash [[ "${1}" == "-v" ]] && printf 'Core version is v6.1.4\n' MOCK # Peer "m" carries Mullvad's shared-exit-node tag and must be filtered out. - cat > "${BIN_SANDBOX}/tailscale" <<'MOCK' + cat > "${dir}/tailscale" <<'MOCK' #!/usr/bin/env bash if [[ "${1}" == "status" && "${2}" == "--json" ]]; then printf '{"Version":"1.90.2","BackendState":"Running","MagicDNSSuffix":"tail1.ts.net",' @@ -107,18 +109,18 @@ if [[ "${1}" == "status" && "${2}" == "--json" ]]; then fi MOCK - cat > "${BIN_SANDBOX}/unbound-checkconf" < "${dir}/unbound-checkconf" <<'MOCK' #!/usr/bin/env bash -case "\${2}" in - config-file) printf '${TEST_TMPDIR}/etc/unbound/unbound.conf\n' ;; +case "${2}" in + config-file) printf '%s\n' "${TEST_TMPDIR}/etc/unbound/unbound.conf" ;; interface) printf '127.0.0.1\n' ;; port) printf '5335\n' ;; - auto-trust-anchor-file) printf '${TEST_TMPDIR}/var/lib/unbound/root.key\n' ;; + auto-trust-anchor-file) printf '%s\n' "${TEST_TMPDIR}/var/lib/unbound/root.key" ;; *) exit 0 ;; esac MOCK - cat > "${BIN_SANDBOX}/sqlite3" <<'MOCK' + cat > "${dir}/sqlite3" <<'MOCK' #!/usr/bin/env bash case "${2}" in *gravity*) printf '128432\n' ;; @@ -126,36 +128,41 @@ case "${2}" in esac MOCK - cat > "${BIN_SANDBOX}/hostname" <<'MOCK' + cat > "${dir}/hostname" <<'MOCK' #!/usr/bin/env bash printf 'pi\n' MOCK - cat > "${BIN_SANDBOX}/stat" <<'MOCK' + cat > "${dir}/stat" <<'MOCK' #!/usr/bin/env bash [[ "${1}" == "-c" && "${2}" == "%s" ]] && exec /usr/bin/stat -f%z "${3}" exec /usr/bin/stat "$@" MOCK - chmod +x "${BIN_SANDBOX}/pihole-FTL" "${BIN_SANDBOX}/pihole" "${BIN_SANDBOX}/tailscale" \ - "${BIN_SANDBOX}/unbound-checkconf" "${BIN_SANDBOX}/sqlite3" "${BIN_SANDBOX}/hostname" \ - "${BIN_SANDBOX}/stat" - - mkdir -p "${TEST_TMPDIR}/etc/unbound/unbound.conf.d" "${TEST_TMPDIR}/etc/pihole" \ - "${TEST_TMPDIR}/var/lib/unbound" "${TEST_TMPDIR}/out" - printf 'server:\n port: 5335\n' > "${TEST_TMPDIR}/etc/unbound/unbound.conf.d/pi-hole.conf" - printf 'gravity\n' > "${TEST_TMPDIR}/etc/pihole/gravity.db" - # The trust anchor lives outside the config directory, as on Debian. - printf 'anchor\n' > "${TEST_TMPDIR}/var/lib/unbound/root.key" + chmod +x "${dir}"/* awk ' /^# Self-elevate if not root/ { skip = 1 } skip && /^fi$/ { skip = 0; next } skip { next } { print } - ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" > "${TEST_TMPDIR}/ph-backup" + ' "${PROJECT_ROOT}/stow/pihole.linux/.local/bin/ph-backup" > "${BATS_FILE_TMPDIR}/ph-backup" + chmod +x "${BATS_FILE_TMPDIR}/ph-backup" +} + +# Links the mocks into BIN_SANDBOX and the copy into TEST_TMPDIR, and lays out +# the Pi-hole and Unbound files. $1 is the Teleporter export's exit status. +setup_ph_backup_sandbox() { + export PH_BACKUP_TELEPORTER_STATUS="${1:-0}" + ln -s "${PH_BACKUP_MOCKS}"/* "${BIN_SANDBOX}/" + ln -s "${BATS_FILE_TMPDIR}/ph-backup" "${TEST_TMPDIR}/ph-backup" - chmod +x "${TEST_TMPDIR}/ph-backup" + mkdir -p "${TEST_TMPDIR}/etc/unbound/unbound.conf.d" "${TEST_TMPDIR}/etc/pihole" \ + "${TEST_TMPDIR}/var/lib/unbound" "${TEST_TMPDIR}/out" + printf 'server:\n port: 5335\n' > "${TEST_TMPDIR}/etc/unbound/unbound.conf.d/pi-hole.conf" + printf 'gravity\n' > "${TEST_TMPDIR}/etc/pihole/gravity.db" + # The trust anchor lives outside the config directory, as on Debian. + printf 'anchor\n' > "${TEST_TMPDIR}/var/lib/unbound/root.key" } run_ph_backup_probe() { @@ -166,11 +173,12 @@ run_ph_backup_probe() { "${TEST_TMPDIR}/ph-backup" -o "${TEST_TMPDIR}/out" } -# One run of the default sandbox, shared by the tests that only read its -# output and archive; PH_BACKUP_RUN stands in for their TEST_TMPDIR. +# The mocks, and one run of the default sandbox shared by the tests that only +# read its output and archive; PH_BACKUP_RUN stands in for their TEST_TMPDIR. setup_file() { local TEST_TMPDIR="${BATS_FILE_TMPDIR}/run" BIN_SANDBOX="${BATS_FILE_TMPDIR}/run/bin" - export DOTFILES_DIR="${PROJECT_ROOT}" + export TEST_TMPDIR DOTFILES_DIR="${PROJECT_ROOT}" PH_BACKUP_MOCKS="${BATS_FILE_TMPDIR}/mocks" + write_ph_backup_mocks "${PH_BACKUP_MOCKS}" mkdir -p "${BIN_SANDBOX}" setup_ph_backup_sandbox run_ph_backup_probe @@ -337,6 +345,7 @@ load_ph_backup_run() { # The Pi has no sqlite3 package at all; a stub that returns nothing stands # in for that, since the host running these tests has its own sqlite3. + rm "${BIN_SANDBOX}/sqlite3" cat > "${BIN_SANDBOX}/sqlite3" <<'MOCK' #!/usr/bin/env bash exit 1 From 5413fbc6844b4b95cdc37bd8a1130b2a2d32995c Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:26:34 -0700 Subject: [PATCH 06/10] Speed up link.sh runs and link.bats, and show test 11's diff --- scripts/link.sh | 143 ++++++++++++++++++++++++++++++++++-------------- tests/link.bats | 70 ++++++++++++++---------- 2 files changed, 144 insertions(+), 69 deletions(-) diff --git a/scripts/link.sh b/scripts/link.sh index 1ed5288..4456f99 100755 --- a/scripts/link.sh +++ b/scripts/link.sh @@ -434,9 +434,9 @@ save_persona() { # This platform's own directory of setup scripts. bootstrap_dir() { - case "$(platform)" in + case "${PLATFORM_DETECTED}" in darwin) printf '%s/scripts/bootstrap/macos\n' "${REPO}" ;; - *) printf '%s/scripts/bootstrap/%s\n' "${REPO}" "$(platform)" ;; + *) printf '%s/scripts/bootstrap/%s\n' "${REPO}" "${PLATFORM_DETECTED}" ;; esac } @@ -461,7 +461,8 @@ optional_installs() { for script in "${dir}"/*.sh; do [[ -f "${script}" ]] || continue grep -q '^# detect: ' "${script}" || continue - name="$(basename "${script}" .sh)" + name="${script##*/}" + name="${name%.sh}" [[ " ${seen} " != *" ${name} "* ]] || continue seen="${seen} ${name}" printf '%s\n' "${name}" @@ -537,7 +538,8 @@ visit_install() { # DOTFILES_INSTALLS (set, maybe empty), else the saved choice, else the # defaults; a saved one no longer offered is dropped with a warning. load_installs() { - local saved name offered kept="" + local saved name offered kept="" all + all="$(offered_installs)" if [[ -n "${DOTFILES_INSTALLS+x}" ]]; then INSTALLS="${DOTFILES_INSTALLS}" INSTALLS_SOURCE=env else @@ -546,10 +548,10 @@ load_installs() { IFS= read -r INSTALLS < "${saved}" || true INSTALLS_SOURCE=saved else - INSTALLS="$(offered_installs | awk -F'\t' '$2 == "default" { print $1 }' | tr '\n' ' ')" INSTALLS_SOURCE=default + INSTALLS="$(awk -F'\t' '$2 == "default" { print $1 }' <<< "${all}" | tr '\n' ' ')" INSTALLS_SOURCE=default fi fi - offered="$(offered_installs | cut -f1)" + offered="$(cut -f1 <<< "${all}")" for name in ${INSTALLS}; do if grep -qxF "${name}" <<< "${offered}"; then kept="${kept} ${name}" @@ -617,7 +619,7 @@ warn_missing_layers() { packages_in() { local kind="$1" layer name for layer in ${PERSONA_LAYERS}; do - for name in "${layer}" "${layer}.$(platform)"; do + for name in "${layer}" "${layer}.${PLATFORM_DETECTED}"; do [[ -d "${REPO}/${kind}/${name}" ]] && printf '%s\n' "${name}" done done @@ -638,12 +640,36 @@ package_entries() { done } -# Relative paths of every file and symlink in one package. +# Relative paths of every file and symlink in one package, from the listings +# list_packages_once made when there are any. package_files() { - local dir="${REPO}/$1/$2" + local dir="${REPO}/$1/$2" i + for i in ${PACKAGE_KEYS[@]+"${!PACKAGE_KEYS[@]}"}; do + if [[ "${PACKAGE_KEYS[i]}" == "$1/$2" ]]; then + [[ -z "${PACKAGE_LISTS[i]}" ]] || printf '%s\n' "${PACKAGE_LISTS[i]}" + return 0 + fi + done (cd "${dir}" && find . \( -type f -o -type l \) ! -name .DS_Store -print | sed 's#^\./##' | LC_ALL=C sort) } +# List every package once, for apply and status: they never change the repo +# and would otherwise list each package up to four times. +PACKAGE_KEYS=() +PACKAGE_LISTS=() +list_packages_once() { + local dir kind list + for dir in "${REPO}"/stow/*/ "${REPO}"/seed/*/ "${REPO}"/editors/*/; do + [[ -d "${dir}" ]] || continue + dir="${dir%/}" + kind="${dir%/*}" + kind="${kind##*/}" + list="$(package_files "${kind}" "${dir##*/}")" || continue + PACKAGE_LISTS+=("${list}") + PACKAGE_KEYS+=("${kind}/${dir##*/}") + done +} + # The directory part of a path, as dirname gives it, without the process. dir_of() { local path="$1" @@ -669,12 +695,18 @@ physical_path() { printf '%s/%s\n' "${dir}" "${path##*/}" } -# An absolute path with its . and .. segments folded, by string alone: no -# symlink is followed and nothing is forked. -normalize_path() { - local part out="" +# Sets LINK_PATH to where the link $1 with text $2 points: an absolute text as +# it is, else the text from the link's directory with its . and .. segments +# folded, by string alone. Every link is checked here, so nothing is followed +# or forked, not even a subshell. +LINK_PATH="" +link_text_path() { + local part out="" path="./$2" local -a parts=() - IFS=/ read -r -a parts <<< "$1" + LINK_PATH="$2" + [[ "$2" != /* ]] || return 0 + [[ "$1" != */* ]] || path="${1%/*}/$2" + IFS=/ read -r -a parts <<< "${path}" for part in ${parts[@]+"${parts[@]}"}; do case "${part}" in '' | .) ;; @@ -682,7 +714,7 @@ normalize_path() { *) out="${out}/${part}" ;; esac done - printf '%s\n' "${out:-/}" + LINK_PATH="${out:-/}" } # True when the destination already resolves to the package file. Fast string @@ -692,8 +724,8 @@ is_linked() { local target="$1" source="$2" resolved [[ -L "${target}" ]] || return 1 resolved="$(readlink "${target}")" || return 1 - [[ "${resolved}" == /* ]] || resolved="$(normalize_path "$(dir_of "${target}")/${resolved}")" - [[ "${resolved}" == "${source}" ]] && return 0 + link_text_path "${target}" "${resolved}" + [[ "${LINK_PATH}" == "${source}" ]] && return 0 resolved="$(physical_path "${target}")" || return 1 [[ "${resolved}" == "$(physical_path "${source}")" ]] } @@ -741,8 +773,8 @@ is_repo_link() { local destination [[ -L "$1" ]] || return 1 destination="$(readlink "$1")" || return 1 - [[ "${destination}" == /* ]] || destination="$(normalize_path "$(dir_of "$1")/${destination}")" - [[ "${destination}" == "${REPO}/stow/"* || "${destination}" == "${REPO}/editors/"* ]] && return 0 + link_text_path "$1" "${destination}" + [[ "${LINK_PATH}" == "${REPO}/stow/"* || "${LINK_PATH}" == "${REPO}/editors/"* ]] && return 0 destination="$(link_destination "$1")" || return 1 [[ "${destination}" == "${REPO}/stow/"* || "${destination}" == "${REPO}/editors/"* ]] } @@ -765,7 +797,8 @@ managed_links() { stale_candidates() { local dir line entry os for dir in "${REPO}/stow"/*/; do - [[ -d "${dir}" ]] && package_files stow "$(basename "${dir}")" + dir="${dir%/}" + [[ -d "${dir}" ]] && package_files stow "${dir##*/}" done if command -v git >/dev/null 2>&1 && git -C "${REPO}" rev-parse --git-dir >/dev/null 2>&1; then while IFS= read -r line; do @@ -783,15 +816,21 @@ stale_candidates() { done } +# True when the lines in $1 include $2, as grep -Fxq would say, without the +# process. +has_line() { + [[ $'\n'"$1"$'\n' == *$'\n'"$2"$'\n'* ]] +} + # Absolute paths of stale links: repo links that are not managed here. stale_links() { local managed relative target managed="$(managed_links)" while IFS= read -r relative; do [[ -n "${relative}" ]] || continue + has_line "${managed}" "${relative}" && continue target="${DEST}/${relative}" is_repo_link "${target}" || continue - grep -Fxq -- "${relative}" <<< "${managed}" && continue printf '%s\n' "${target}" done < <(stale_candidates | LC_ALL=C sort -u) stale_agent_links @@ -857,36 +896,52 @@ persona_skill_bundles() { # Absolute paths of links in ~/.agents/skills into a bundle another persona # lists but this one does not. stale_skill_links() { - local skills="${DEST}/${SKILLS_DIR}" home bundles target bundle listed + local skills="${DEST}/${SKILLS_DIR}" home bundles target bundle listed="" [[ "${PERSONA_AGENTS}" == yes && -d "${skills}" && ! -L "${skills}" ]] || return 0 home="$(cd -P "${DEST}" && pwd)" || return 0 bundles="${home}/.config/agents/skills/" - listed="$(persona_skill_bundles)" while IFS= read -r target; do bundle="$(link_destination "${target}")" || continue [[ "${bundle}" == "${bundles}"* ]] || continue bundle="${bundle#"${bundles}"}" bundle="${bundle%%/*}" [[ " ${PERSONA_SKILLS} " == *" ${bundle} "* ]] && continue - grep -Fxq -- "${bundle}" <<< "${listed}" && printf '%s\n' "${target}" + [[ -n "${listed}" ]] || listed="$(persona_skill_bundles)" + has_line "${listed}" "${bundle}" && printf '%s\n' "${target}" done < <(find "${skills}" -mindepth 1 -maxdepth 1 -type l | LC_ALL=C sort) } -# A path as the reader knows it: ~/... when linking into $HOME, and relative -# to the repo for the repo's own files. -show_path() { +# Sets SHOWN to a path as the reader knows it: ~/... when linking into $HOME, +# and relative to the repo for the repo's own files. +SHOWN="" +shown_path() { local path="$1" home="~" if [[ "${path}" == "${REPO}/"* ]]; then - printf '%s\n' "${path#"${REPO}"/}" + SHOWN="${path#"${REPO}"/}" elif [[ "${DEST}" == "${HOME}" && "${path}" == "${DEST}" ]]; then - printf '%s\n' "${home}" + SHOWN="${home}" elif [[ "${DEST}" == "${HOME}" && "${path}" == "${DEST}/"* ]]; then - printf '%s/%s\n' "${home}" "${path#"${DEST}"/}" + SHOWN="${home}/${path#"${DEST}"/}" else - printf '%s\n' "${path}" + SHOWN="${path}" fi } +show_path() { + shown_path "$1" + printf '%s\n' "${SHOWN}" +} + +# Prints "VERB TARGET -> SOURCE" with both paths shown, without a subshell for +# each: every link a run makes is named here. +print_link() { + local target + shown_path "$2" + target="${SHOWN}" + shown_path "$3" + printf '%s %s -> %s\n' "$1" "${target}" "${SHOWN}" +} + # Links counted across the run for the closing summary. TOTAL_LINKS=0 TOTAL_IN_PLACE=0 @@ -1019,12 +1074,12 @@ place_link() { clear_target "${target}" "${source}" || return 0 PLACED=new if [[ "${DRY_RUN}" -eq 1 ]]; then - printf 'Would link %s -> %s\n' "$(show_path "${target}")" "$(show_path "${source}")" + print_link 'Would link' "${target}" "${source}" return 0 fi mkdir -p "$(dirname "${target}")" ln -sfn "${source}" "${target}" - printf 'Linked %s -> %s\n' "$(show_path "${target}")" "$(show_path "${source}")" + print_link 'Linked' "${target}" "${source}" } # Place each TARGET|SOURCE line on stdin and report them under LABEL. @@ -1070,9 +1125,9 @@ report_stow() { for entry in ${STOW_PENDING[@]+"${STOW_PENDING[@]}"}; do target="${DEST}/${entry#*|}" source="${REPO}/stow/${entry%%|*}/${entry#*|}" if [[ "${DRY_RUN}" -eq 1 ]]; then - printf 'Would link %s -> %s\n' "$(show_path "${target}")" "$(show_path "${source}")" + print_link 'Would link' "${target}" "${source}" elif is_linked "${target}" "${source}"; then - printf 'Linked %s -> %s\n' "$(show_path "${target}")" "$(show_path "${source}")" + print_link 'Linked' "${target}" "${source}" in_place=$((in_place + 1)) else continue @@ -1338,18 +1393,20 @@ path_prefixes() { unlinked_layer_paths() { local dir layer relative shared for layer in $(for dir in "${REPO}"/stow/*/; do - if [[ -d "${dir}" ]]; then dir="$(basename "${dir}")"; printf '%s\n' "${dir%%.*}"; fi + dir="${dir%/}" + if [[ -d "${dir}" ]]; then dir="${dir##*/}"; printf '%s\n' "${dir%%.*}"; fi done | LC_ALL=C sort -u); do [[ " ${PERSONA_LAYERS} " == *" ${layer} "* ]] && continue shared="$(for dir in "${REPO}"/stow/*/; do - dir="$(basename "${dir}")" + dir="${dir%/}" + dir="${dir##*/}" if [[ -d "${REPO}/stow/${dir}" && "${dir%%.*}" != "${layer}" ]]; then package_files stow "${dir}"; fi done | path_prefixes | LC_ALL=C sort -u)" for dir in "${REPO}/stow/${layer}" "${REPO}/stow/${layer}".*; do - if [[ -d "${dir}" ]]; then package_files stow "$(basename "${dir}")"; fi + if [[ -d "${dir}" ]]; then package_files stow "${dir##*/}"; fi done | path_prefixes | LC_ALL=C sort -u | while IFS= read -r relative; do - grep -Fxq -- "${relative}" <<< "${shared}" && continue - if [[ "${relative}" == */* ]] && ! grep -Fxq -- "${relative%/*}" <<< "${shared}"; then + has_line "${shared}" "${relative}" && continue + if [[ "${relative}" == */* ]] && ! has_line "${shared}" "${relative%/*}"; then continue fi # An empty folder is what pruning stale links leaves behind. @@ -2099,11 +2156,15 @@ main() { done ;; managed) print_managed ;; - status) print_status ;; + status) + list_packages_once + print_status + ;; add) add_files ;; rm) remove_files ;; mv) move_files ;; apply) + list_packages_once if [[ "${PERSONA_SOURCE}" == default ]]; then log_warn "No persona chosen; linking as '${PERSONA_NAME}', the default for $(platform). Pick one with: install.sh --persona NAME" fi diff --git a/tests/link.bats b/tests/link.bats index c53dc50..cc34a7e 100644 --- a/tests/link.bats +++ b/tests/link.bats @@ -6,6 +6,8 @@ load test_helper LINK="scripts/link.sh" +# The repo as link.sh names it, with every symlink in its path resolved. +REPO_ROOT="$(cd "${PROJECT_ROOT}" && pwd -P)" # Stands in for the private agents repo. Built once: link.sh only clones from # it, so every test shares it; a test that commits to it copies it first @@ -30,6 +32,32 @@ setup_file() { git -C "${AGENTS_FIXTURE}" init -q git -C "${AGENTS_FIXTURE}" add -A git -C "${AGENTS_FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m fixture + + # No gum, as on CI: a gum process per warning costs more than the run. + PATH="$(path_without gum)" + export PATH + NO_STOW_PATH="$(path_without stow)" + export NO_STOW_PATH +} + +# PATH with each directory that holds NAME swapped for a directory of links +# to everything else in it. +path_without() { + local name="$1" dir farm out="" i=0 + local -a dirs=() + IFS=: read -r -a dirs <<< "${PATH}" + for dir in "${dirs[@]}"; do + if [[ -x "${dir}/${name}" ]]; then + i=$((i + 1)) + farm="${BATS_FILE_TMPDIR}/no-${name}-${i}" + mkdir -p "${farm}" + ln -s "${dir}"/* "${farm}/" + rm -f "${farm}/${name}" + dir="${farm}" + fi + out="${out:+${out}:}${dir}" + done + printf '%s\n' "${out}" } # A private copy of the agents fixture for a test that changes it. @@ -79,22 +107,6 @@ backup_of() { find "${DEST}/.local/state/dotfiles/clobbered" -path "*/$1" 2>/dev/null | head -1 } -# Sets NO_STOW_PATH to a directory of links to every command on PATH except stow. -hide_stow() { - local dir entry name - local -a dirs=() - NO_STOW_PATH="${TEST_TMPDIR}/no-stow-bin" - mkdir -p "${NO_STOW_PATH}" - IFS=: read -r -a dirs <<< "${PATH}" - for dir in "${dirs[@]}"; do - for entry in "${dir}"/*; do - name="${entry##*/}" - [[ "${name}" == stow || -e "${NO_STOW_PATH}/${name}" || ! -x "${entry}" ]] && continue - ln -s "${entry}" "${NO_STOW_PATH}/${name}" - done - done -} - stat_mode() { stat -f %Lp "$1" 2>/dev/null || stat -c %a "$1" } @@ -125,7 +137,7 @@ stat_mode() { local app f for app in Code Cursor; do for f in settings.json keybindings.json; do - [ "$(readlink "${DEST}/Library/Application Support/${app}/User/${f}")" = "${PROJECT_ROOT}/editors/code/${f}" ] + [ "$(readlink "${DEST}/Library/Application Support/${app}/User/${f}")" = "${REPO_ROOT}/editors/code/${f}" ] done done [ -d "${DEST}/Library/Application Support/Code/User" ] && [ ! -L "${DEST}/Library/Application Support/Code/User" ] @@ -243,14 +255,15 @@ stat_mode() { command -v stow >/dev/null 2>&1 || skip "stow not installed" run_link assert_success - local before - before="$(cd "${DEST}" && find . -print | LC_ALL=C sort)" + local before="${TEST_TMPDIR}/before" + (cd "${DEST}" && find . -print | LC_ALL=C sort) > "${before}" run_link assert_success refute_output --partial "Backed up" refute_output --partial "Created" - [ "$(cd "${DEST}" && find . -print | LC_ALL=C sort)" = "${before}" ] + run diff "${before}" <(cd "${DEST}" && find . -print | LC_ALL=C sort) + assert_success } @test "a run names each link it makes and sums up every step" { @@ -328,7 +341,6 @@ stat_mode() { } @test "dry run without stow still reports conflicts" { - hide_stow mkdir -p "${DEST}/.config/git" printf 'my local edit\n' > "${DEST}/.config/git/ignore" @@ -339,7 +351,6 @@ stat_mode() { } @test "a real run without stow fails before moving anything" { - hide_stow mkdir -p "${DEST}/.config/git" printf 'my local edit\n' > "${DEST}/.config/git/ignore" @@ -975,7 +986,8 @@ PY # --------------------------------------------------------------------------- # The fixture repo with an executable tool and an app directory of two files -# committed in common, all linked into the destination. +# committed in common, all linked into the destination. A function named as the +# argument changes the repo before the one link run. make_linked_fixture() { make_fixture_repo mkdir -p "${FIXTURE}/stow/common/.local/bin" "${FIXTURE}/stow/common/.config/app/themes" @@ -985,6 +997,7 @@ make_linked_fixture() { printf 'b\n' > "${FIXTURE}/stow/common/.config/app/themes/dark" git -C "${FIXTURE}" add -A git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m three + [[ -z "${1:-}" ]] || "$1" run_fixture_link assert_success [ -L "${DEST}/.local/bin/tool" ] && [ -L "${DEST}/.config/app/themes/dark" ] @@ -1131,7 +1144,10 @@ fixture_untracks() { # The linked fixture with home linking common and extra, server also linking # solo, a layer home does not, and work linking only common. make_move_fixture() { - make_linked_fixture + make_linked_fixture write_move_personas +} + +write_move_personas() { local name layers for name in home server work; do case "${name}" in @@ -1144,8 +1160,6 @@ make_move_fixture() { done git -C "${FIXTURE}" add -A git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m four - run_fixture_link - assert_success } # Where a link in the destination points inside the fixture's stow/. @@ -1705,8 +1719,8 @@ plant_single_layer_links() { assert_success refute_output --partial "Backed up" [ "$(readlink "${DEST}/.local/bin/sshkey")" = "../../.dotfiles/stow/tools/.local/bin/sshkey" ] \ - || [ "$(cd "${DEST}/.local/bin" && cd -P "$(dirname "$(readlink sshkey)")" && pwd)" = "${PROJECT_ROOT}/stow/tools/.local/bin" ] - [ "$(cd "${DEST}/.config/ghostty" && cd -P "$(dirname "$(readlink config.ghostty)")" && pwd)" = "${PROJECT_ROOT}/stow/desktop/.config/ghostty" ] + || [ "$(cd "${DEST}/.local/bin" && cd -P "$(dirname "$(readlink sshkey)")" && pwd)" = "${REPO_ROOT}/stow/tools/.local/bin" ] + [ "$(cd "${DEST}/.config/ghostty" && cd -P "$(dirname "$(readlink config.ghostty)")" && pwd)" = "${REPO_ROOT}/stow/desktop/.config/ghostty" ] [ ! -L "${DEST}/.local/bin/ph-update" ] [ ! -L "${DEST}/.local/bin/ph-backup" ] [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] From bf5e28612a74e919e4b2043eeffec1595914a5a9 Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:47:45 -0700 Subject: [PATCH 07/10] Keep link.sh lists whole when a write is interrupted --- scripts/link.sh | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/scripts/link.sh b/scripts/link.sh index 4456f99..0f22bea 100755 --- a/scripts/link.sh +++ b/scripts/link.sh @@ -782,37 +782,42 @@ is_repo_link() { # Relative paths of the links that belong here: this platform's stow files and # editor links. managed_links() { - local entry + local entry stow editors + stow="$(package_entries stow)" + editors="$(editor_links)" while IFS= read -r entry; do - printf '%s\n' "${entry#*|}" - done < <(package_entries stow) + [[ -z "${entry}" ]] || printf '%s\n' "${entry#*|}" + done <<< "${stow}" while IFS= read -r entry; do - printf '%s\n' "${entry%%|*}" - done < <(editor_links) + [[ -z "${entry}" ]] || printf '%s\n' "${entry%%|*}" + done <<< "${editors}" } # Relative paths where a repo link may have been left behind: every file any # stow package has held, now or in the repo's history, and whatever sits in # any platform's editor directories. stale_candidates() { - local dir line entry os + local dir line entry os list for dir in "${REPO}/stow"/*/; do dir="${dir%/}" [[ -d "${dir}" ]] && package_files stow "${dir##*/}" done + # Lists are captured before printing, as in package_entries. if command -v git >/dev/null 2>&1 && git -C "${REPO}" rev-parse --git-dir >/dev/null 2>&1; then + list="$(git -C "${REPO}" -c core.quotePath=false log --format= --name-only -- stow 2>/dev/null || true)" while IFS= read -r line; do [[ "${line}" == stow/*/* ]] && printf '%s\n' "${line#stow/*/}" - done < <(git -C "${REPO}" -c core.quotePath=false log --format= --name-only -- stow 2>/dev/null || true) + done <<< "${list}" fi for os in darwin linux; do + list="$(editor_dirs "${os}")" while IFS= read -r entry; do dir="${DEST}/${entry%%|*}" - [[ -d "${dir}" && ! -L "${dir}" ]] || continue + [[ -n "${entry}" && -d "${dir}" && ! -L "${dir}" ]] || continue for line in "${dir}"/*; do [[ -L "${line}" ]] && printf '%s\n' "${line#"${DEST}"/}" done - done < <(editor_dirs "${os}") + done <<< "${list}" done } @@ -1319,28 +1324,34 @@ warn_unused_agents_checkout() { log_warn "${DEST}/.config/agents holds the private agents repo, which this persona does not use. It is left in place; delete it yourself if this machine should not keep it." } -# destination|source for every shared editor file on this platform. +# destination|source for every shared editor file on this platform. Like +# package_entries, each list is captured before anything is printed. editor_links() { - local entry dir name relative + local entry dir name relative dirs files [[ "${PERSONA_EDITORS}" == yes ]] || return 0 + dirs="$(editor_dirs)" while IFS= read -r entry; do + [[ -n "${entry}" ]] || continue dir="${entry%%|*}" name="${entry#*|}" [[ -d "${REPO}/editors/${name}" ]] || continue + files="$(package_files editors "${name}")" + [[ -n "${files}" ]] || continue while IFS= read -r relative; do printf '%s/%s|%s/editors/%s/%s\n' "${dir}" "${relative}" "${REPO}" "${name}" "${relative}" - done < <(package_files editors "${name}") - done < <(editor_dirs) + done <<< "${files}" + done <<< "${dirs}" } # Same rules as the stow packages: an identical copy is replaced, anything # else is backed up first. link_editors() { - local entry + local entry links place_links "Editor settings" < <( + links="$(editor_links)" while IFS= read -r entry; do - printf '%s|%s\n' "${DEST}/${entry%%|*}" "${entry#*|}" - done < <(editor_links) + [[ -z "${entry}" ]] || printf '%s|%s\n' "${DEST}/${entry%%|*}" "${entry#*|}" + done <<< "${links}" ) } From 9457305b296d6ef747db7992e309394acf0117ee Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:13:59 -0700 Subject: [PATCH 08/10] Shard CI's Bats run five ways and lint in the lightest --- .github/workflows/ci.yml | 41 ++++++++++++++++++++-------------------- tests/README.md | 7 +++++-- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9654e2c..99d7862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,26 +13,16 @@ concurrency: cancel-in-progress: true jobs: - lint: - name: shellcheck - runs-on: macos-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install shellcheck - run: brew install shellcheck - - name: Run shellcheck - run: bash tests/run_tests.sh --lint-only - test: name: bats (${{ matrix.shard }}) # macOS runner: a few tests are macOS-only, and this is the OS the suite is # written against. The runner image ships bash 3.2 and neither shellcheck # nor stow, like a fresh Mac. runs-on: macos-latest - # Three shards of about equal time on CI's runners, run side by side: - # link.bats, doctor.bats with install.bats, and a third that globs every - # file not named in NAMED, so a new .bats file runs there without a change - # here. + # Five shards of about equal time on CI's runners, the most macOS jobs an + # account runs at once. The last globs every file not named in NAMED, so a + # new .bats file runs there without a change here, and lints first with the + # same Homebrew shellcheck a Mac has. strategy: fail-fast: false matrix: @@ -40,14 +30,23 @@ jobs: - shard: link files: tests/link.bats - shard: doctor-install - files: tests/doctor.bats tests/install.bats - - shard: others + files: tests/doctor.bats tests/install.bats tests/bootstrap.bats + - shard: bin + files: tests/bin_scripts.bats tests/bin_pihole.bats tests/bin_firetv.bats + - shard: helpers + files: tests/common.bats tests/bin_ph_backup.bats tests/brew.bats tests/ai_doctor.bats tests/ph.bats + - shard: lint-others files: '' + lint: true # The bats suite shells out to TUI tools (tput/stty); give it a sane TERM so # terminal-aware scripts behave as they do in an interactive shell. env: TERM: xterm - NAMED: tests/link.bats tests/doctor.bats tests/install.bats + NAMED: >- + tests/link.bats tests/doctor.bats tests/install.bats tests/bootstrap.bats + tests/bin_scripts.bats tests/bin_pihole.bats tests/bin_firetv.bats + tests/common.bats tests/bin_ph_backup.bats tests/brew.bats + tests/ai_doctor.bats tests/ph.bats FILES: ${{ matrix.files }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -59,10 +58,12 @@ jobs: with: path: ~/.cache/dotfiles/tests key: test-deps-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/deps.sh') }} - # The machine's own tools, which run_tests.sh requires but never installs - # (shellcheck only for the lint job). + # The machine's own tools, which run_tests.sh requires but never installs. - name: Install stow and uv - run: brew install stow uv + run: brew install stow uv ${{ matrix.lint && 'shellcheck' || '' }} + - name: Run shellcheck + if: matrix.lint + run: bash tests/run_tests.sh --lint-only - name: Fetch test dependencies run: bash tests/deps.sh - name: Run bats shard diff --git a/tests/README.md b/tests/README.md index 354bafd..fcdca53 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,7 @@ The Bats suite for the repo. Run it with `./tests/run_tests.sh`; the [root `AGEN - The suite's own tools live in `~/.cache/dotfiles/tests` (`XDG_CACHE_HOME` or `DOTFILES_TEST_DEPS` move it), which `deps.sh` fills on the first run: `bats-core`, `bats-support`, `bats-assert`, GNU `parallel`, a `python3` link to a uv-managed Python 3.11+ (without `uv`, the tests that need it skip), and on macOS a `bash` 5 built from source. Each download is checked against the sha256 pinned in `deps.sh`; a rerun with everything present is offline and silent; `rm -rf ~/.cache/dotfiles/tests` starts over. Nothing is installed through Homebrew for the tests. The machine's own `shellcheck` and `stow` are required, not installed (`Brewfile.core`, apt), and on Debian `bzip2`. - Bats runs on the cache's bash through a `bash` shim first on `PATH` that sends only Bats' own programs there: `bash -c`, `run bash script`, and `#!/usr/bin/env bash` scripts under test still run on `/bin/bash`, as on a fresh Mac. Code a test sources in-process runs on the new bash. The cache's `python3` comes first on `PATH`, its `bats` and `parallel` last. - Bumping a pin: set the version and the tarball's sha256 in `deps.sh` (cross-check with the GNU `.sig` or `brew info --json=v2 NAME`), then delete the cache directory. CI's cache key is the file's hash. -- CI ([`ci.yml`](../.github/workflows/ci.yml)) runs on every push as four parallel macOS jobs: one `--lint-only` and three `--no-lint` Bats shards: `link.bats`; `doctor.bats` with `install.bats`; and every other `*.bats` file by glob, so a new file runs there without a workflow change. Move a file between shards (and in `NAMED`) when one shard grows well past the others. +- CI ([`ci.yml`](../.github/workflows/ci.yml)) runs on every push as five parallel macOS Bats shards, the most macOS jobs an account runs at once, each listing its files in the matrix. The last also runs `--lint-only` and takes every `*.bats` file not in `NAMED` by glob, so a new file runs there without a workflow change. Move a file between shards (and in `NAMED`) when one shard grows well past the others. ## Docs lint @@ -32,7 +32,10 @@ chmod +x .githooks/pre-commit .githooks/pre-push Tests run against scratch homes, overridden `PATH`, and stub executables: process-level isolation, not a container. Each `.bats` file names its cases; this list says only what each is for. - `ai_doctor.bats`: `ai-doctor` against fixture Claude Code, Codex, and Cursor config and a fake `claude`: each kind of row, each change and its undo, `--reset`, the menus, and that nothing changes without them. -- `bin_scripts.bats`: the scripts in `stow/*/.local/bin` other than `ai-doctor` and `ph`, with their external commands mocked. +- `bin_scripts.bats`: `os-doctor`, `cf-cert`, `ts-test`, `sshkey`, the `df-` wrappers, `df-deploy`, and `st`, with their external commands mocked. +- `bin_firetv.bats`: `firetv` against a mocked `adb` and `ph`. +- `bin_ph_backup.bats`: `ph-backup` against a scratch Pi-hole, Unbound, and Tailscale. +- `bin_pihole.bats`: `ph-sec-audit`, `ph-padd`, `ph-update`, and `ph-test`, with their external commands mocked. - `bootstrap.bats`: `apt-packages.txt`, the Linux and macOS bootstrap scripts, the optional installs, `pihole.sh` (`PIHOLE_ROOT`), `harden.sh` dry runs (`HARDEN_ROOT`), `iterm2.sh`, `bootstrap.sh`, and script conventions (shebang, strict mode, `common.sh`). - `brew_add.bats`: `brew-add.sh` and `df-add` against scratch Brewfiles and a stub `brew`. - `brew.bats`: the Brewfiles' shape and `brew.sh` against a mocked brew. From 477263e3b991dfebe4b36aa30274fe1a2e76d27e Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:23:45 -0700 Subject: [PATCH 09/10] Move bootstrap.bats to the lighter helpers shard --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d7862..eb4d00e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,11 @@ jobs: - shard: link files: tests/link.bats - shard: doctor-install - files: tests/doctor.bats tests/install.bats tests/bootstrap.bats + files: tests/doctor.bats tests/install.bats - shard: bin files: tests/bin_scripts.bats tests/bin_pihole.bats tests/bin_firetv.bats - shard: helpers - files: tests/common.bats tests/bin_ph_backup.bats tests/brew.bats tests/ai_doctor.bats tests/ph.bats + files: tests/bootstrap.bats tests/common.bats tests/bin_ph_backup.bats tests/brew.bats tests/ai_doctor.bats tests/ph.bats - shard: lint-others files: '' lint: true From ac5e9461f24e88cce24991b4c871b0dafd5fd9ba Mon Sep 17 00:00:00 2001 From: smithbr <1495361+smithbr@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:01:45 -0700 Subject: [PATCH 10/10] Split link.bats in two and rebalance the shards --- .github/workflows/ci.yml | 23 +- tests/README.md | 4 +- tests/link.bats | 1066 +------------------------------------- tests/link_helper.bash | 170 ++++++ tests/link_ops.bats | 905 ++++++++++++++++++++++++++++++++ 5 files changed, 1091 insertions(+), 1077 deletions(-) create mode 100644 tests/link_helper.bash create mode 100644 tests/link_ops.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb4d00e..e00c7c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,14 +27,14 @@ jobs: fail-fast: false matrix: include: - - shard: link - files: tests/link.bats - - shard: doctor-install - files: tests/doctor.bats tests/install.bats - - shard: bin - files: tests/bin_scripts.bats tests/bin_pihole.bats tests/bin_firetv.bats - - shard: helpers - files: tests/bootstrap.bats tests/common.bats tests/bin_ph_backup.bats tests/brew.bats tests/ai_doctor.bats tests/ph.bats + - shard: link-install + files: tests/link.bats tests/install.bats tests/brew.bats + - shard: link-doctor + files: tests/link_ops.bats tests/doctor.bats tests/brew_add.bats + - shard: bin-bootstrap + files: tests/bin_scripts.bats tests/bootstrap.bats tests/bin_ph_backup.bats + - shard: bin-common + files: tests/bin_pihole.bats tests/bin_firetv.bats tests/common.bats tests/ai_doctor.bats tests/ph.bats - shard: lint-others files: '' lint: true @@ -43,9 +43,10 @@ jobs: env: TERM: xterm NAMED: >- - tests/link.bats tests/doctor.bats tests/install.bats tests/bootstrap.bats - tests/bin_scripts.bats tests/bin_pihole.bats tests/bin_firetv.bats - tests/common.bats tests/bin_ph_backup.bats tests/brew.bats + tests/link.bats tests/install.bats tests/brew.bats + tests/link_ops.bats tests/doctor.bats tests/brew_add.bats + tests/bin_scripts.bats tests/bootstrap.bats tests/bin_ph_backup.bats + tests/bin_pihole.bats tests/bin_firetv.bats tests/common.bats tests/ai_doctor.bats tests/ph.bats FILES: ${{ matrix.files }} steps: diff --git a/tests/README.md b/tests/README.md index fcdca53..5efa56b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -46,12 +46,14 @@ Tests run against scratch homes, overridden `PATH`, and stub executables: proces - `lint_docs.bats`: `lint-docs.sh` against scratch repos with one fault each. - `harden_test.bats`: `harden-test.sh` against a scratch root (`HARDEN_ROOT`) with mocked system tools. - `install.bats`: `install.sh`'s arguments, prompts, SSH key step, and an isolated integration run with mocked commands. -- `link.bats`: the real `link.sh` and GNU Stow against a scratch home: linking, stale links, backups, personas, agent links, seed files, `status`, `managed`, the installs, git identity, `add`, `rm`, and `mv`. +- `link.bats`: the real `link.sh` and GNU Stow against a scratch home: linking, stale links, backups, agent links, seed files, `status`, `managed`, and `add`. +- `link_ops.bats`: the same against the same fixtures: `rm`, `mv`, personas, layers, git identity, and the installs. - `ph.bats`: `ph` against a mocked `curl` answering from fixtures. - `repo_config.bats`: the shipped personas and roles, every tracked JSON, TOML, YAML, and `.itermcolors` file, the shell startup files, and the vimrc. - `runner.bats`: the runner itself: a failing `[[ ]]` before a test's last line fails the test, and a script under test still runs on `/bin/bash`. - `zsh.bats`: the zsh and bash startup files. - `test_helper.bash`: shared setup: `PROJECT_ROOT`, a temporary directory per test, fixture personas. +- `link_helper.bash`: the agents fixture, fixture repos, and `link.sh` runners both link suites load. ## Writing tests diff --git a/tests/link.bats b/tests/link.bats index e46ca04..152bc51 100644 --- a/tests/link.bats +++ b/tests/link.bats @@ -4,118 +4,7 @@ # test touches the network or the real home directory. load test_helper - -LINK="scripts/link.sh" -# The repo as link.sh names it, with every symlink in its path resolved. -REPO_ROOT="$(cd "${PROJECT_ROOT}" && pwd -P)" - -# Stands in for the private agents repo. Built once: link.sh only clones from -# it, so every test shares it; a test that commits to it copies it first -# (own_agents_fixture). -setup_file() { - export AGENTS_FIXTURE="${BATS_FILE_TMPDIR}/agents-origin" - mkdir -p "${AGENTS_FIXTURE}/skills/core/demo" "${AGENTS_FIXTURE}/skills/extra/other" \ - "${AGENTS_FIXTURE}/skills/vault/notes" \ - "${AGENTS_FIXTURE}/agents" - # Every bundle a shipped persona asks for, so no run warns about one - # missing (each warning is a gum process). - local bundle - for bundle in $(sed -n 's/^skills=//p' "${PROJECT_ROOT}"/personas/* | tr ' ' '\n' | sort -u); do - mkdir -p "${AGENTS_FIXTURE}/skills/${bundle}/${bundle}-demo" - printf 'skill\n' > "${AGENTS_FIXTURE}/skills/${bundle}/${bundle}-demo/SKILL.md" - done - printf 'rules\n' > "${AGENTS_FIXTURE}/AGENTS.md" - printf 'agent\n' > "${AGENTS_FIXTURE}/agents/demo.md" - printf 'skill\n' > "${AGENTS_FIXTURE}/skills/core/demo/SKILL.md" - printf 'skill\n' > "${AGENTS_FIXTURE}/skills/extra/other/SKILL.md" - printf 'skill\n' > "${AGENTS_FIXTURE}/skills/vault/notes/SKILL.md" - git -C "${AGENTS_FIXTURE}" init -q - git -C "${AGENTS_FIXTURE}" add -A - git -C "${AGENTS_FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m fixture - - # No gum, as on CI: a gum process per warning costs more than the run. - PATH="$(path_without gum)" - export PATH - NO_STOW_PATH="$(path_without stow)" - export NO_STOW_PATH -} - -# PATH with each directory that holds NAME swapped for a directory of links -# to everything else in it. -path_without() { - local name="$1" dir farm out="" i=0 - local -a dirs=() - IFS=: read -r -a dirs <<< "${PATH}" - for dir in "${dirs[@]}"; do - if [[ -x "${dir}/${name}" ]]; then - i=$((i + 1)) - farm="${BATS_FILE_TMPDIR}/no-${name}-${i}" - mkdir -p "${farm}" - ln -s "${dir}"/* "${farm}/" - rm -f "${farm}/${name}" - dir="${farm}" - fi - out="${out:+${out}:}${dir}" - done - printf '%s\n' "${out}" -} - -# A private copy of the agents fixture for a test that changes it. -own_agents_fixture() { - cp -R "${AGENTS_FIXTURE}" "${TEST_TMPDIR}/agents-origin" - AGENTS_FIXTURE="${TEST_TMPDIR}/agents-origin" - export AGENTS_REPO_URL="file://${AGENTS_FIXTURE}" -} - -setup() { - setup_tmpdir - DEST="${TEST_TMPDIR}/home" - mkdir -p "${DEST}" - export AGENTS_REPO_URL="file://${AGENTS_FIXTURE}" - export DOTFILES_OS=darwin - # Keeps the machine's own Homebrew (and its tool skills) out of the tests. - export HOMEBREW_PREFIX="${TEST_TMPDIR}/brew" -} - -teardown() { - teardown_tmpdir -} - -run_link() { - run bash "${PROJECT_ROOT}/${LINK}" --destination "${DEST}" "$@" -} - -# A copy of the repo whose personas also hold "bare": the common layer only, -# no agents, no editors, on either OS. No real persona is that spare. -make_bare_repo() { - BARE="${TEST_TMPDIR}/bare-repo" - mkdir -p "${BARE}/scripts" - cp -R "${PROJECT_ROOT}/stow" "${PROJECT_ROOT}/editors" "${PROJECT_ROOT}/seed" "${PROJECT_ROOT}/personas" "${PROJECT_ROOT}/roles" "${BARE}/" - cp "${PROJECT_ROOT}/scripts/link.sh" "${PROJECT_ROOT}/scripts/common.sh" "${BARE}/scripts/" - printf 'os=darwin,linux\nlayers=common\nagents=no\nagent_apps=\nskills=\neditors=no\nbrew=\nbrew_optional=\nroles=\n' > "${BARE}/personas/bare" -} - -run_bare_link() { - run bash "${BARE}/scripts/link.sh" --repo "${BARE}" --destination "${DEST}" "$@" -} - -package_file() { - printf '%s/stow/%s\n' "${PROJECT_ROOT}" "$1" -} - -backup_of() { - find "${DEST}/.local/state/dotfiles/clobbered" -path "*/$1" 2>/dev/null | head -1 -} - -stat_mode() { - stat -f %Lp "$1" 2>/dev/null || stat -c %a "$1" -} - -# Every path under the destination except the agents checkout's .git: what -# git keeps there (locks, maintenance files) is git's doing, not link.sh's. -dest_paths() { - (cd "${DEST}" && find . -name .git -prune -o -print | LC_ALL=C sort) -} +load link_helper # --------------------------------------------------------------------------- # Linking packages @@ -707,30 +596,6 @@ dest_paths() { # Stale links # --------------------------------------------------------------------------- -# Sets FIXTURE to a small dotfiles repo whose history held stow/common/.config/gone/rc -# and stow/linux/.config/tool/rc, and which now holds stow/common/.keep, a -# stow/extra package this platform does not use, and editors/code/settings.json. -make_fixture_repo() { - FIXTURE="${TEST_TMPDIR}/fixture" - mkdir -p "${FIXTURE}/stow/common/.config/gone" "${FIXTURE}/stow/linux/.config/tool" \ - "${FIXTURE}/stow/extra/.config/extra" "${FIXTURE}/editors/code" - printf 'keep\n' > "${FIXTURE}/stow/common/.keep" - printf 'gone\n' > "${FIXTURE}/stow/common/.config/gone/rc" - printf 'tool\n' > "${FIXTURE}/stow/linux/.config/tool/rc" - printf 'extra\n' > "${FIXTURE}/stow/extra/.config/extra/rc" - printf '{}\n' > "${FIXTURE}/editors/code/settings.json" - write_test_persona "${FIXTURE}" yes - git -C "${FIXTURE}" init -q - git -C "${FIXTURE}" add -A - git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m one - git -C "${FIXTURE}" rm -q -r stow/common/.config/gone stow/linux - git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m two -} - -run_fixture_link() { - run bash "${PROJECT_ROOT}/${LINK}" --repo "${FIXTURE}" --destination "${DEST}" "$@" -} - # Leaves the links a run from the fixture's first commit would have made, on # both platforms: two now dangle, one points into an unused package, and one is # an editor link in the Linux editor directory. @@ -903,36 +768,6 @@ plant_old_links() { assert_output --partial "only apply to add" } -# Runs link.sh with stdin on a pseudo-terminal and the answer typed there, -# since the layer prompt asks only on a terminal. Output is what the terminal -# showed, with its line endings normalized. -run_fixture_link_on_tty() { - local answer="$1" - shift - command -v python3 >/dev/null 2>&1 || skip "python3 not available" - run python3 - "${answer}" bash "${PROJECT_ROOT}/${LINK}" --repo "${FIXTURE}" --destination "${DEST}" "$@" <<'PY' -import os, pty, sys -answer, cmd = sys.argv[1], sys.argv[2:] -pid, fd = pty.fork() -if pid == 0: - os.environ["DOTFILES_PROMPT"] = "read" - os.execvp(cmd[0], cmd) -os.write(fd, (answer + "\n").encode()) -out = b"" -while True: - try: - chunk = os.read(fd, 4096) - except OSError: - break - if not chunk: - break - out += chunk -_, status = os.waitpid(pid, 0) -sys.stdout.write(out.decode(errors="replace").replace("\r\n", "\n")) -sys.exit(status >> 8 if os.WIFEXITED(status) else 1) -PY -} - @test "add describes each layer on a terminal, by its rule or else its files, and takes the answer by number or name" { command -v stow >/dev/null 2>&1 || skip "stow not installed" make_fixture_repo @@ -989,902 +824,3 @@ PY assert_failure assert_output --partial "Not adding a symlink" } - -# --------------------------------------------------------------------------- -# Removing files -# --------------------------------------------------------------------------- - -# The fixture repo with an executable tool and an app directory of two files -# committed in common, all linked into the destination. A function named as the -# argument changes the repo before the one link run. -make_linked_fixture() { - make_fixture_repo - mkdir -p "${FIXTURE}/stow/common/.local/bin" "${FIXTURE}/stow/common/.config/app/themes" - printf '#!/bin/sh\necho tool\n' > "${FIXTURE}/stow/common/.local/bin/tool" - chmod 750 "${FIXTURE}/stow/common/.local/bin/tool" - printf 'a\n' > "${FIXTURE}/stow/common/.config/app/config" - printf 'b\n' > "${FIXTURE}/stow/common/.config/app/themes/dark" - git -C "${FIXTURE}" add -A - git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m three - [[ -z "${1:-}" ]] || "$1" - run_fixture_link - assert_success - [ -L "${DEST}/.local/bin/tool" ] && [ -L "${DEST}/.config/app/themes/dark" ] -} - -fixture_tracks() { - git -C "${FIXTURE}" ls-files --error-unmatch -- "$1" >/dev/null 2>&1 -} - -# A bare "! cmd" never fails a Bats test, so the negation lives in a function. -fixture_untracks() { - ! fixture_tracks "$1" -} - -@test "rm keeps the file as a real copy with its mode and takes it out of the layer and the index" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_linked_fixture - - run_fixture_link rm "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "Kept ~/.local/bin/tool as a real file" - assert_output --partial "Removed stow/common/.local/bin/tool from the repo" - assert_output --partial "/fixture commit" - assert_output --partial "run df-rm on it before pulling" - - [ -f "${DEST}/.local/bin/tool" ] && [ ! -L "${DEST}/.local/bin/tool" ] - [ "$(cat "${DEST}/.local/bin/tool")" = "$(printf '#!/bin/sh\necho tool')" ] - [ "$(stat_mode "${DEST}/.local/bin/tool")" = 750 ] - [ ! -e "${FIXTURE}/stow/common/.local/bin/tool" ] - [ ! -e "${FIXTURE}/stow/common/.local" ] - [ -d "${FIXTURE}/stow/common" ] - fixture_untracks stow/common/.local/bin/tool - [ -z "$(find "${DEST}/.local/bin" -name '.df-rm.*')" ] - - # The next link run leaves the real file alone. - run_fixture_link - assert_success - [ -f "${DEST}/.local/bin/tool" ] && [ ! -L "${DEST}/.local/bin/tool" ] -} - -@test "rm takes a directory and removes every managed link in it" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_linked_fixture - printf 'mine\n' > "${DEST}/.config/app/local" - ln -s /etc/hosts "${DEST}/.config/app/hosts" - - cd "${DEST}/.config" - run_fixture_link rm app < /dev/null - assert_success - [ "$(cat "${DEST}/.config/app/config")" = a ] && [ ! -L "${DEST}/.config/app/config" ] - [ "$(cat "${DEST}/.config/app/themes/dark")" = b ] && [ ! -L "${DEST}/.config/app/themes/dark" ] - [ "$(readlink "${DEST}/.config/app/hosts")" = /etc/hosts ] - [ "$(cat "${DEST}/.config/app/local")" = mine ] - [ ! -e "${FIXTURE}/stow/common/.config/app" ] - fixture_untracks stow/common/.config/app/config - fixture_untracks stow/common/.config/app/themes/dark - [ -L "${DEST}/.local/bin/tool" ] - fixture_tracks stow/common/.local/bin/tool -} - -@test "rm refuses real files, foreign and non-repo links, and outside paths before changing anything" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_linked_fixture - mkdir -p "${FIXTURE}/seed/common/.config/seeded" "${DEST}/.config/seeded" - printf 'seed\n' > "${FIXTURE}/seed/common/.config/seeded/state" - printf 'seed\n' > "${DEST}/.config/seeded/state" - printf 'real\n' > "${DEST}/.real" - ln -s /etc/hosts "${DEST}/.hosts" - ln -s "${DEST}/.config/agents/AGENTS.md" "${DEST}/.agents-md" - printf 'outside\n' > "${TEST_TMPDIR}/outside" - local before - before="$(git -C "${FIXTURE}" status --porcelain)" - - for path in .real .hosts .agents-md .config/seeded/state "Library/Application Support/Code/User/settings.json"; do - run_fixture_link rm "${DEST}/.local/bin/tool" "${DEST}/${path}" < /dev/null - assert_failure - assert_output --partial "Not removing ~/${path}" - refute_output --partial "Kept" - done - run_fixture_link rm "${DEST}/.config/seeded/state" < /dev/null - assert_output --partial "a seed file" - run_fixture_link rm "${DEST}/.hosts" < /dev/null - assert_output --partial "links outside the repo" - run_fixture_link rm "${DEST}/.local/bin/tool" "${TEST_TMPDIR}/outside" < /dev/null - assert_failure - assert_output --partial "Not inside" - run_fixture_link rm "${DEST}" < /dev/null - assert_failure - assert_output --partial "Not inside" - run_fixture_link rm "${DEST}/.config/seeded" < /dev/null - assert_failure - assert_output --partial "No managed files to remove" - - [ -L "${DEST}/.local/bin/tool" ] - fixture_tracks stow/common/.local/bin/tool - [ "$(git -C "${FIXTURE}" status --porcelain)" = "${before}" ] - [ "$(readlink "${DEST}/.hosts")" = /etc/hosts ] - [ "$(cat "${DEST}/.real")" = real ] -} - -@test "rm dry run changes nothing" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_linked_fixture - - run_fixture_link --dry-run rm "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "Would replace the link ~/.local/bin/tool with a copy of stow/common/.local/bin/tool" - run_fixture_link --dry-run rm --delete "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "Would move ~/.local/bin/tool to ${DEST}/.local/state/dotfiles/cleanup/" - - [ -L "${DEST}/.local/bin/tool" ] - [ -f "${FIXTURE}/stow/common/.local/bin/tool" ] - fixture_tracks stow/common/.local/bin/tool - [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] - [ ! -e "${DEST}/.local/state/dotfiles/cleanup" ] -} - -@test "rm --delete moves the file to the cleanup folder instead of keeping it" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_linked_fixture - - run_fixture_link rm --delete "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "Moved ~/.local/bin/tool to ${DEST}/.local/state/dotfiles/cleanup/" - [ ! -e "${DEST}/.local/bin/tool" ] && [ ! -L "${DEST}/.local/bin/tool" ] - local archived - archived="$(find "${DEST}/.local/state/dotfiles/cleanup" -path '*.remove/.local/bin/tool')" - [ -n "${archived}" ] && [ ! -L "${archived}" ] - [ "$(stat_mode "${archived}")" = 750 ] - grep -q 'echo tool' "${archived}" - [ "$(stat_mode "${archived%/.local/bin/tool}")" = 700 ] - fixture_untracks stow/common/.local/bin/tool - - run_fixture_link --delete status - assert_failure - assert_output --partial "--delete only applies to rm" -} - -# --------------------------------------------------------------------------- -# Moving files between layers -# --------------------------------------------------------------------------- - -# The linked fixture with home linking common and extra, server also linking -# solo, a layer home does not, and work linking only common. -make_move_fixture() { - make_linked_fixture write_move_personas -} - -write_move_personas() { - local name layers - for name in home server work; do - case "${name}" in - home) layers="common extra" ;; - server) layers="common extra solo" ;; - work) layers="common" ;; - esac - printf 'os=darwin,linux\nlayers=%s\nagents=yes\nagent_apps=claude cursor codex\nskills=core\neditors=yes\nbrew=\nbrew_optional=\nroles=\n' \ - "${layers}" > "${FIXTURE}/personas/${name}" - done - git -C "${FIXTURE}" add -A - git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m four -} - -# Where a link in the destination points inside the fixture's stow/. -linked_package_path() { - local text - text="$(readlink "$1")" - printf '%s\n' "stow/${text##*/stow/}" -} - -@test "mv moves a file to another layer and keeps it linked" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - - run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "Moved stow/common/.local/bin/tool to stow/extra/.local/bin/tool; ~/.local/bin/tool links there" - assert_output --partial "/fixture commit" - assert_output --partial "one whose persona does not link 'extra' (linked by home, server) loses the file there" - - [ -L "${DEST}/.local/bin/tool" ] && [ -x "${DEST}/.local/bin/tool" ] - [ "$(cat "${DEST}/.local/bin/tool")" = "$(printf '#!/bin/sh\necho tool')" ] - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] - [[ "$(readlink "${DEST}/.local/bin/tool")" != /* ]] - [ ! -e "${FIXTURE}/stow/common/.local" ] - run git -C "${FIXTURE}" diff --cached -M --name-status - assert_output "$(printf 'R100\tstow/common/.local/bin/tool\tstow/extra/.local/bin/tool')" - [ -z "$(find "${DEST}/.local/bin" -name '.df-mv.*')" ] - - # The next link run keeps the new link and backs nothing up. - run_fixture_link - assert_success - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] - [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] -} - -@test "mv --platform moves into this OS's part, keeps it by default, and --no-platform moves out" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - - run_fixture_link mv --layer common --platform "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "every persona links 'common'" - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common.darwin/.local/bin/tool ] - - run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" < /dev/null - assert_success - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra.darwin/.local/bin/tool ] - [ ! -e "${FIXTURE}/stow/common.darwin/.local" ] - - run_fixture_link mv --layer extra --no-platform "${DEST}/.local/bin/tool" < /dev/null - assert_success - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] - [ "$(cat "${DEST}/.local/bin/tool")" = "$(printf '#!/bin/sh\necho tool')" ] - [ -f "${FIXTURE}/stow/extra/.local/bin/tool" ] - [ ! -e "${FIXTURE}/stow/extra.darwin/.local" ] -} - -@test "mv takes a directory and moves every managed link in it" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - printf 'mine\n' > "${DEST}/.config/app/local" - ln -s /etc/hosts "${DEST}/.config/app/hosts" - - cd "${DEST}/.config" - run_fixture_link mv --layer extra app < /dev/null - assert_success - [ "$(linked_package_path "${DEST}/.config/app/config")" = stow/extra/.config/app/config ] - [ "$(linked_package_path "${DEST}/.config/app/themes/dark")" = stow/extra/.config/app/themes/dark ] - [ "$(cat "${DEST}/.config/app/themes/dark")" = b ] - [ "$(readlink "${DEST}/.config/app/hosts")" = /etc/hosts ] - [ "$(cat "${DEST}/.config/app/local")" = mine ] - [ ! -e "${FIXTURE}/stow/common/.config/app" ] - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common/.local/bin/tool ] -} - -@test "mv asks for the layer on a terminal and needs one without it" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - - run_fixture_link mv "${DEST}/.local/bin/tool" < /dev/null - assert_failure - assert_output --partial "Name the layer with --layer (one of: common extra)" - - run_fixture_link_on_tty extra mv "${DEST}/.local/bin/tool" - assert_success - assert_output --partial "Layer to move the files to" - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] -} - -@test "mv refuses bad paths, unlinked layers, the same place, and taken destinations with nothing changed" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - mkdir -p "${FIXTURE}/seed/common/.config/seeded" "${DEST}/.config/seeded" "${FIXTURE}/stow/extra/.config/app" - printf 'seed\n' > "${FIXTURE}/seed/common/.config/seeded/state" - printf 'seed\n' > "${DEST}/.config/seeded/state" - printf 'taken\n' > "${FIXTURE}/stow/extra/.config/app/config" - printf 'real\n' > "${DEST}/.real" - ln -s /etc/hosts "${DEST}/.hosts" - ln -s "${DEST}/.config/agents/AGENTS.md" "${DEST}/.agents-md" - printf 'outside\n' > "${TEST_TMPDIR}/outside" - local before - before="$(git -C "${FIXTURE}" status --porcelain)" - - for path in .real .hosts .agents-md .config/seeded/state "Library/Application Support/Code/User/settings.json"; do - run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" "${DEST}/${path}" < /dev/null - assert_failure - assert_output --partial "Not moving ~/${path}" - refute_output --partial "Moved" - done - run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" "${TEST_TMPDIR}/outside" < /dev/null - assert_failure - assert_output --partial "Not inside" - - run_fixture_link mv --layer solo "${DEST}/.local/bin/tool" < /dev/null - assert_failure - assert_output --partial "does not link layer 'solo' (linked by server)" - run_fixture_link mv --layer common "${DEST}/.local/bin/tool" < /dev/null - assert_failure - assert_output --partial "Not moving ~/.local/bin/tool: already in stow/common" - run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" "${DEST}/.config/app" < /dev/null - assert_failure - assert_output --partial "Not moving ~/.config/app/config: stow/extra/.config/app/config already exists" - run_fixture_link mv --layer extra --platform --no-platform "${DEST}/.local/bin/tool" < /dev/null - assert_failure - assert_output --partial "contradict" - run_fixture_link rm --no-platform "${DEST}/.local/bin/tool" < /dev/null - assert_failure - assert_output --partial "--no-platform only applies to mv" - - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common/.local/bin/tool ] - [ "$(linked_package_path "${DEST}/.config/app/themes/dark")" = stow/common/.config/app/themes/dark ] - [ "$(git -C "${FIXTURE}" status --porcelain)" = "${before}" ] - [ "$(cat "${FIXTURE}/stow/extra/.config/app/config")" = taken ] -} - -@test "mv dry run changes nothing" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - - run_fixture_link --dry-run mv --layer extra "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "Would move stow/common/.local/bin/tool to stow/extra/.local/bin/tool and relink ~/.local/bin/tool" - [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common/.local/bin/tool ] - [ ! -e "${FIXTURE}/stow/extra/.local" ] - [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] -} - -@test "mv lists references to the old repo path without editing them" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - mkdir -p "${FIXTURE}/tests" - printf 'first\nrun stow/common/.local/bin/tool here\n' > "${FIXTURE}/tests/check" - git -C "${FIXTURE}" add -A - git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m refs - - run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" < /dev/null - assert_success - assert_output --partial "References to stow/common/.local/bin/tool, left for you to update:" - assert_output --partial " tests/check:2:run stow/common/.local/bin/tool here" - grep -q 'stow/common/.local/bin/tool' "${FIXTURE}/tests/check" -} - -@test "mv renames the repo file of a link renamed in ~ and keeps its layer" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - mv "${DEST}/.config/app/config" "${DEST}/.config/app/settings" - - run_fixture_link --dry-run mv "${DEST}/.config/app/settings" < /dev/null - assert_success - assert_output --partial "Would move stow/common/.config/app/config to stow/common/.config/app/settings and relink ~/.config/app/settings" - [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] - - run_fixture_link mv "${DEST}/.config/app/settings" < /dev/null - assert_success - assert_output --partial "Moved stow/common/.config/app/config to stow/common/.config/app/settings; ~/.config/app/settings links there" - assert_output --partial "Renamed 1 file(s) in the repo to match ~" - refute_output --partial "Layer to move" - [ "$(cat "${DEST}/.config/app/settings")" = a ] - [ "$(linked_package_path "${DEST}/.config/app/settings")" = stow/common/.config/app/settings ] - run git -C "${FIXTURE}" diff --cached -M --name-status - assert_output "$(printf 'R100\tstow/common/.config/app/config\tstow/common/.config/app/settings')" - - # The next link run keeps the new name and brings back nothing at the old one. - run_fixture_link - assert_success - [ ! -e "${DEST}/.config/app/config" ] && [ ! -L "${DEST}/.config/app/config" ] - [ "$(linked_package_path "${DEST}/.config/app/settings")" = stow/common/.config/app/settings ] - [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] -} - -@test "mv still asks for the layer of a renamed file on a terminal, defaulting to its own" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - mv "${DEST}/.config/app/config" "${DEST}/.config/app/settings" - mv "${DEST}/.config/app/themes/dark" "${DEST}/.config/app/themes/night" - - run_fixture_link_on_tty "" mv "${DEST}/.config/app/settings" - assert_success - assert_output --partial "Layer to move the files to" - assert_output --partial "Choice [common]" - assert_output --partial "Renamed 1 file(s) in the repo to match ~" - [ "$(linked_package_path "${DEST}/.config/app/settings")" = stow/common/.config/app/settings ] - - run_fixture_link_on_tty extra mv "${DEST}/.config/app/themes/night" - assert_success - assert_output --partial "Moved 1 file(s) to layer extra" - [ "$(linked_package_path "${DEST}/.config/app/themes/night")" = stow/extra/.config/app/themes/night ] - [ "$(cat "${DEST}/.config/app/themes/night")" = b ] -} - -@test "mv follows a link moved to another depth, whose old text no longer resolves" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - mv "${DEST}/.local/bin/tool" "${DEST}/.config/tool" - [ ! -e "${DEST}/.config/tool" ] - - run_fixture_link mv "${DEST}/.config/tool" < /dev/null - assert_success - [ -x "${DEST}/.config/tool" ] - [ "$(cat "${DEST}/.config/tool")" = "$(printf '#!/bin/sh\necho tool')" ] - [ "$(linked_package_path "${DEST}/.config/tool")" = stow/common/.config/tool ] - [[ "$(readlink "${DEST}/.config/tool")" != /* ]] - [ ! -e "${FIXTURE}/stow/common/.local" ] -} - -@test "mv follows a renamed directory, and --layer changes the layer too" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - mv "${DEST}/.config/app" "${DEST}/.config/app2" - - run_fixture_link mv --layer extra "${DEST}/.config/app2" < /dev/null - assert_success - [ "$(linked_package_path "${DEST}/.config/app2/config")" = stow/extra/.config/app2/config ] - [ "$(linked_package_path "${DEST}/.config/app2/themes/dark")" = stow/extra/.config/app2/themes/dark ] - [ "$(cat "${DEST}/.config/app2/themes/dark")" = b ] - [ ! -e "${FIXTURE}/stow/common/.config/app" ] -} - -@test "mv refuses a rename that add would refuse, with nothing changed" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_move_fixture - mv "${DEST}/.config/app/config" "${DEST}/.config/app/config.local" - - run_fixture_link mv "${DEST}/.config/app/config.local" < /dev/null - assert_failure - assert_output --partial "Not moving ~/.config/app/config.local: a .local file holds this machine's overrides" - [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] - [ -f "${FIXTURE}/stow/common/.config/app/config" ] -} - -# --------------------------------------------------------------------------- -# Personas -# --------------------------------------------------------------------------- - -persona_state() { - cat "${DEST}/.local/state/dotfiles/persona" -} - -@test "lists the personas this OS can use" { - run_link personas - assert_success - assert_line "home" - assert_line "work" - DOTFILES_OS=linux run_link personas - assert_success - refute_line "home" - assert_line "server" - assert_line "vps" -} - -@test "uses the OS default until a persona is chosen, and says so" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - run_link persona - assert_output "home default" - DOTFILES_OS=linux run_link persona - assert_output "server default" - - run_link - assert_success - assert_output --partial "No persona chosen" - [ ! -e "${DEST}/.local/state/dotfiles/persona" ] -} - -@test "a chosen persona is saved privately and reused by later runs" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - run_link --persona work - assert_success - assert_output --partial "Saved persona work" - [ "$(persona_state)" = "work" ] - [ "$(stat_mode "${DEST}/.local/state/dotfiles")" = "700" ] - - run_link persona - assert_output "work saved" - run_link - assert_success - refute_output --partial "No persona chosen" - refute_output --partial "Saved persona" -} - -@test "the persona from the environment is used and saved like the flag" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - DOTFILES_PERSONA=server run_link persona - assert_output "server env" - DOTFILES_PERSONA=server run_link - assert_success - [ "$(persona_state)" = "server" ] - # The flag wins over the environment. - DOTFILES_PERSONA=server run_link --persona work persona - assert_output "work flag" -} - -@test "a dry run never saves the persona" { - run_link --dry-run --persona work - assert_success - [ ! -e "${DEST}/.local/state/dotfiles/persona" ] -} - -@test "a persona without agents or editors links only the shared files" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - make_bare_repo - run_bare_link --persona bare - assert_success - [ -L "${DEST}/.zshenv" ] - [ ! -e "${DEST}/.config/agents" ] - [ ! -e "${DEST}/.cursor/AGENTS.md" ] - [ ! -e "${DEST}/Library/Application Support/Code" ] - - run_bare_link managed - refute_line "${DEST}/.config/agents" - refute_line --partial "Application Support" - run_bare_link status - assert_success - assert_output "unset ${DEST}/.config/git/config.local" -} - -@test "switching to a persona without editors removes the editor links" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - run_link --persona home - assert_success - [ -L "${DEST}/Library/Application Support/Code/User/settings.json" ] - - run_link --persona server - assert_success - assert_output --partial "Removed stale link ${DEST}/Library/Application Support/Code/User/settings.json" - [ ! -e "${DEST}/Library/Application Support/Code/User/settings.json" ] - [ -L "${DEST}/.zshenv" ] - [ "$(persona_state)" = "server" ] -} - -@test "rejects an unknown persona or one for another OS before changing anything" { - run_link --persona nope - assert_failure 2 - assert_output --partial "Unknown persona 'nope'" - run_link --persona ../home - assert_failure 2 - DOTFILES_OS=linux run_link --persona home - assert_failure 2 - assert_output --partial "Persona 'home' is not for linux" - [ -z "$(ls -A "${DEST}")" ] -} - -@test "a saved persona that no longer exists fails clearly" { - mkdir -p "${DEST}/.local/state/dotfiles" - printf 'retired\n' > "${DEST}/.local/state/dotfiles/persona" - run_link status - assert_failure 2 - assert_output --partial "Unknown persona 'retired'" -} - -# --------------------------------------------------------------------------- -# Layers -# --------------------------------------------------------------------------- - -@test "each persona links its own layers" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - run_link --persona home - assert_success - assert test -L "${DEST}/.config/ghostty/config.ghostty" - [ -L "${DEST}/.local/bin/sshkey" ] - [ -L "${DEST}/.local/bin/df-deploy" ] - [ ! -e "${DEST}/.local/bin/ph-update" ] - - # A work Mac gets the shared tools but nothing personal or Pi-hole. - DEST="${TEST_TMPDIR}/work-home" - run_link --persona work - assert_success - [ -L "${DEST}/.local/bin/sshkey" ] - for path in .local/bin/cf-cert .local/bin/df-deploy .local/bin/ts-test \ - .local/bin/ph-update .local/bin/ph-padd; do - [ ! -e "${DEST}/${path}" ] - done - - # The Pi-hole tools come with the pihole role, not the server persona. - DEST="${TEST_TMPDIR}/pi-home" - DOTFILES_OS=linux run_link --persona server - assert_success - [ ! -e "${DEST}/.local/bin/ph-update" ] - [ -L "${DEST}/.local/bin/sshkey" ] - DOTFILES_OS=linux DOTFILES_INSTALLS=pihole run_link --persona server - assert_success - [ -L "${DEST}/.local/bin/ph-update" ] - [ -L "${DEST}/.local/bin/ph-backup" ] - [ -L "${DEST}/.local/bin/sshkey" ] - [ -L "${DEST}/.local/bin/cf-cert" ] - [ -L "${DEST}/.local/bin/ts-test" ] - [ ! -e "${DEST}/.config/ghostty" ] - [ ! -e "${DEST}/.config/1Password" ] - - # A Mac server gets the shared tools but not the Linux-only Pi-hole ones. - DEST="${TEST_TMPDIR}/mini-home" - run_link --persona server - assert_success - [ -L "${DEST}/.local/bin/os-doctor" ] - [ ! -e "${DEST}/.local/bin/ph-update" ] - - DEST="${TEST_TMPDIR}/vps-home" - DOTFILES_OS=linux run_link --persona vps - assert_success - [ -L "${DEST}/.zshenv" ] - [ -L "${DEST}/.local/bin/cf-cert" ] - [ ! -e "${DEST}/.local/bin/ph-update" ] -} - -# Prints a git setting as git resolves it with DEST as the home directory. -dest_git_config() { - HOME="${DEST}" XDG_CONFIG_HOME="${DEST}/.config" GIT_CONFIG_NOSYSTEM=1 \ - git config --global --includes --get "$1" -} - -@test "git and ssh pick up the settings of the persona's layers" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - run_link --persona work - assert_success - run dest_git_config core.editor - assert_output code - run dest_git_config gpg.format - assert_output ssh - [ -L "${DEST}/.ssh/config.d/1password" ] - - # config.local overrides a layer setting. - printf '[core]\n editor = vim\n' > "${DEST}/.config/git/config.local" - run dest_git_config core.editor - assert_output vim - - DEST="${TEST_TMPDIR}/pi-home" - DOTFILES_OS=linux run_link --persona server - assert_success - run dest_git_config core.pager - assert_output delta - run dest_git_config merge.tool - assert_output vimdiff - [ ! -e "${DEST}/.ssh/config.d" ] - - DEST="${TEST_TMPDIR}/vps-home" - DOTFILES_OS=linux run_link --persona vps - assert_success - run dest_git_config core.pager - assert_failure -} - -@test "only the personal layer brings a git identity and the gh login" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - run_link --persona home - assert_success - refute_output --partial "No git identity" - [ -f "${DEST}/.config/gh/hosts.yml" ] - run dest_git_config user.email - assert_output --partial smithbr@users.noreply.github.com - - # config.local still wins over the personal identity. - printf '[user]\n email = me@example.com\n' > "${DEST}/.config/git/config.local" - run dest_git_config user.email - assert_output me@example.com - - for persona in work; do - DEST="${TEST_TMPDIR}/${persona}-home" - run_link --persona "${persona}" - assert_success - run dest_git_config user.email - assert_failure - [ ! -e "${DEST}/.config/gh/hosts.yml" ] - run_link --persona "${persona}" - assert_output --partial "No git identity for persona '${persona}'" - run_link --persona "${persona}" status - assert_line "unset ${DEST}/.config/git/config.local" - - printf '[user]\n email = me@work.example\n' > "${DEST}/.config/git/config.local" - run_link --persona "${persona}" - refute_output --partial "No git identity" - done -} - -@test "a [user] in ~/.gitconfig is reported because it overrides config.local" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - printf '[user]\n email = old@example.com\n' > "${DEST}/.gitconfig" - run_link --persona work - assert_success - assert_output --partial "${DEST}/.gitconfig sets [user]" - assert_output --partial "then archive it with df-doctor" - run_link --persona work status - assert_line "shadowed ${DEST}/.gitconfig" - [ "$(cat "${DEST}/.gitconfig")" = "$(printf '[user]\n email = old@example.com')" ] - - printf '[filter "lfs"]\n required = true\n' > "${DEST}/.gitconfig" - run_link --persona work status - refute_line "shadowed ${DEST}/.gitconfig" -} - -@test "status names real files a layer the persona does not link would own" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - mkdir -p "${DEST}/.config/git" "${DEST}/.local/bin" - printf 'old copy\n' > "${DEST}/.local/bin/cf-cert" - printf '[user]\n email = me@work.example\n' > "${DEST}/.config/git/config.local" - run_link --persona work - assert_success - - run_link --persona work status - assert_success - assert_output "unlinked ${DEST}/.local/bin/cf-cert" - [ "$(cat "${DEST}/.local/bin/cf-cert")" = 'old copy' ] - - # A whole folder only an unlinked layer would own is named once, not per - # file. A fresh home: here work has linked that folder already. - local server_home="${TEST_TMPDIR}/server-home" - mkdir -p "${server_home}/.config/ghostty" - printf 'font-size = 14\n' > "${server_home}/.config/ghostty/config" - DEST="${server_home}" run_link --persona server status - assert_success - assert_line "unlinked ${server_home}/.config/ghostty" - refute_output --partial "ghostty/config" - [ "$(cat "${server_home}/.config/ghostty/config")" = 'font-size = 14' ] - - # A persona that links the layer does not report its folder. - rm "${DEST}/.local/bin/cf-cert" - run_link --persona home - assert_success - run_link --persona home status - refute_output --partial unlinked -} - -# Links a run of the single-layer layout left: every tool under stow/common. -plant_single_layer_links() { - local name - mkdir -p "${DEST}/.local/bin" "${DEST}/.config/ghostty" - for name in sshkey ph-update ph-backup; do - ln -s "${PROJECT_ROOT}/stow/common/.local/bin/${name}" "${DEST}/.local/bin/${name}" - done - # A path the repo holds today: a file only in history is stale only when - # git log can see it, and CI's checkout is shallow. - ln -s "${PROJECT_ROOT}/stow/common/.config/ghostty/config.ghostty" "${DEST}/.config/ghostty/config.ghostty" -} - -@test "moving to layers relinks what the persona keeps and removes the rest" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - plant_single_layer_links - run_link --persona home - assert_success - refute_output --partial "Backed up" - [ "$(readlink "${DEST}/.local/bin/sshkey")" = "../../.dotfiles/stow/tools/.local/bin/sshkey" ] \ - || [ "$(cd "${DEST}/.local/bin" && cd -P "$(dirname "$(readlink sshkey)")" && pwd)" = "${REPO_ROOT}/stow/tools/.local/bin" ] - [ "$(cd "${DEST}/.config/ghostty" && cd -P "$(dirname "$(readlink config.ghostty)")" && pwd)" = "${REPO_ROOT}/stow/desktop/.config/ghostty" ] - [ ! -L "${DEST}/.local/bin/ph-update" ] - [ ! -L "${DEST}/.local/bin/ph-backup" ] - [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] - - DEST="${TEST_TMPDIR}/pi-home" - plant_single_layer_links - DOTFILES_OS=linux DOTFILES_INSTALLS=pihole run_link --persona server - assert_success - refute_output --partial "Backed up" - [ -e "${DEST}/.local/bin/ph-update" ] - [ -e "${DEST}/.local/bin/ph-backup" ] - [ ! -L "${DEST}/.config/ghostty/config.ghostty" ] - [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] - DOTFILES_OS=linux run_link status - assert_success - assert_output "" -} - -@test "installs prints what each persona installs, with the optional installs on by default but Holeberry off" { - run_link --persona work installs - assert_success - assert_output "brew=core work -brew_optional=macos -harden=no -installs=claude-code -installs_source=default -install=claude-code claude-code tool_installed claude" - run_link --persona home installs - assert_line "brew=core home" - assert_line "installs=claude-code" - # Picked by name, Holeberry is still installed. - DOTFILES_INSTALLS="holeberry claude-code" run_link --persona work installs - assert_success - assert_line "installs=holeberry claude-code" - assert_line "installs_source=env" - DOTFILES_OS=linux run_link --persona server installs - assert_line "installs=docker tailscale claude-code" - assert_line "brew=core" - DOTFILES_OS=linux run_link --persona vps installs - assert_line "brew=core" - assert_line "brew_optional=" - assert_line "harden=yes" - assert_line "installs=docker tailscale claude-code" -} - -@test "a persona's harden setting must be yes or no" { - local repo="${TEST_TMPDIR}/harden-repo" - mkdir -p "${repo}/stow/common" "${repo}/personas" - printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nharden=maybe\n' > "${repo}/personas/home" - cp "${repo}/personas/home" "${repo}/personas/server" - run bash "${PROJECT_ROOT}/scripts/link.sh" --destination "${DEST}" --repo "${repo}" installs - assert_failure 2 - assert_output --partial "harden must be yes or no, not 'maybe'" -} - -@test "offered lists the optional installs for this OS, then the persona's roles, with each detect command" { - local hb_detect='[[ -d "${HOLEBERRY_APPS_DIR:-/Applications}/Holeberry.app" ]]' - run_link --persona work offered - assert_success - assert_output "holeberry optional ${hb_detect} -claude-code default tool_installed claude" - run_link --persona server offered - assert_output "holeberry optional ${hb_detect} -claude-code default tool_installed claude" - DOTFILES_OS=linux run_link --persona server offered - assert_line --index 0 "docker default tool_installed docker" - assert_line --index 1 "tailscale default tool_installed tailscale" - assert_line --index 2 "claude-code default tool_installed claude" - assert_line "pihole optional [[ -x /usr/bin/pihole-FTL && -x /usr/local/bin/pihole && -x /usr/sbin/unbound && -f /etc/unbound/unbound.conf.d/pi-hole.conf ]]" - # A vps is asked whether it is a Pi-hole too. - DOTFILES_OS=linux run_link --persona vps offered - assert_line --index 0 "docker default tool_installed docker" - assert_line --partial "pihole optional " -} - -@test "optional installs are the setup scripts with a detect line, the OS directory first, on every persona" { - make_bare_repo - mkdir -p "${BARE}/scripts/bootstrap/linux" "${BARE}/scripts/bootstrap/macos" - printf '#!/usr/bin/env bash\n# detect: command -v zed\n' > "${BARE}/scripts/bootstrap/zed.sh" - printf '#!/usr/bin/env bash\n# detect: command -v docker\n' > "${BARE}/scripts/bootstrap/linux/docker.sh" - printf '#!/usr/bin/env bash\n# detect: [[ -d /Applications/Docker.app ]]\n' > "${BARE}/scripts/bootstrap/macos/docker.sh" - # A script without a detect line (an OS bootstrap, a role's setup) is not one. - printf '#!/usr/bin/env bash\necho plain\n' > "${BARE}/scripts/bootstrap/plain.sh" - DOTFILES_OS=linux run_bare_link --persona bare offered - assert_success - assert_output "docker default command -v docker -zed default command -v zed" - DOTFILES_OS=darwin run_bare_link --persona bare offered - assert_line --index 0 "docker default [[ -d /Applications/Docker.app ]]" - DOTFILES_OS=linux run_bare_link --persona bare installs - assert_line "installs=docker zed" - assert_line "installs_source=default" - assert_line "install=zed zed command -v zed" - # A role may require an optional install, which then joins before it. - printf 'os=darwin,linux\nrequires=zed\n' > "${BARE}/roles/needy" - printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nroles_optional=needy\n' > "${BARE}/personas/bare" - DOTFILES_OS=linux DOTFILES_INSTALLS="needy" run_bare_link --persona bare installs - assert_success - assert_line "installs=zed needy" -} - -@test "chosen installs come from DOTFILES_INSTALLS, in run order with what they require, and add their layers" { - DOTFILES_OS=linux DOTFILES_INSTALLS="pihole" run_link --persona server installs - assert_success - assert_line "installs=tailscale pihole" - assert_line "installs_source=env" - assert_line "install=pihole pihole [[ -x /usr/bin/pihole-FTL && -x /usr/local/bin/pihole && -x /usr/sbin/unbound && -f /etc/unbound/unbound.conf.d/pi-hole.conf ]]" - # The pihole role brings the pihole layer; without it a Linux server has no ph-* tools. - DOTFILES_OS=linux DOTFILES_INSTALLS="pihole" run_link --persona server managed - assert_line "${DEST}/.local/bin/ph-update" - DOTFILES_OS=linux DOTFILES_INSTALLS="" run_link --persona server managed - refute_line "${DEST}/.local/bin/ph-update" - - DOTFILES_OS=darwin DOTFILES_INSTALLS="pihole" run_link --persona server installs - assert_failure 2 - assert_output --partial "does not offer install 'pihole' on darwin" - DOTFILES_OS=linux DOTFILES_INSTALLS="docker" run_link --persona server installs - assert_line "installs=docker" -} - -@test "chosen installs are saved with the persona and a persona switch drops the ones it does not offer" { - command -v stow >/dev/null 2>&1 || skip "stow not installed" - DOTFILES_OS=linux DOTFILES_INSTALLS="pihole docker" run_link --persona server - assert_success - assert_output --partial "Saved installs tailscale pihole docker" - [ "$(cat "${DEST}/.local/state/dotfiles/installs")" = "tailscale pihole docker" ] - [ -L "${DEST}/.local/bin/ph-update" ] - - DOTFILES_OS=linux run_link installs - assert_line "installs=tailscale pihole docker" - assert_line "installs_source=saved" - - # Every Linux persona offers pihole; the same persona on macOS does not. - DOTFILES_OS=darwin run_link --persona server - assert_success - assert_output --partial "does not offer the saved install 'pihole'" - [ ! -e "${DEST}/.local/bin/ph-update" ] - DOTFILES_OS=darwin DOTFILES_INSTALLS="" run_link --persona server - assert_output --partial "Saved installs none" - [ "$(cat "${DEST}/.local/state/dotfiles/installs")" = "" ] - - DOTFILES_OS=linux DOTFILES_INSTALLS="docker" run_link --dry-run --persona server - assert_success - [ "$(cat "${DEST}/.local/state/dotfiles/installs")" = "" ] -} - -@test "a persona naming a role the repo lacks, or a role requiring one, fails clearly" { - make_bare_repo - printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nroles=missing\n' > "${BARE}/personas/bare" - run_bare_link --persona bare installs - assert_failure 2 - assert_output --partial "no roles/missing for roles" - printf 'os=darwin,linux\nrequires=nothing\n' > "${BARE}/roles/odd" - printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nroles_optional=odd\n' > "${BARE}/personas/bare" - DOTFILES_INSTALLS=odd run_bare_link --persona bare installs - assert_failure 2 - assert_output --partial "roles/odd requires 'nothing', which neither roles/ nor scripts/bootstrap/ has" -} diff --git a/tests/link_helper.bash b/tests/link_helper.bash new file mode 100644 index 0000000..d66cb25 --- /dev/null +++ b/tests/link_helper.bash @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Shared by link.bats and link_ops.bats: the fixtures and runners for +# scripts/link.sh. Loaded after test_helper. + +LINK="scripts/link.sh" +# The repo as link.sh names it, with every symlink in its path resolved. +# shellcheck disable=SC2034 # read by the suites that load this file +REPO_ROOT="$(cd "${PROJECT_ROOT}" && pwd -P)" + +# Stands in for the private agents repo. Built once: link.sh only clones from +# it, so every test shares it; a test that commits to it copies it first +# (own_agents_fixture). +setup_file() { + export AGENTS_FIXTURE="${BATS_FILE_TMPDIR}/agents-origin" + mkdir -p "${AGENTS_FIXTURE}/skills/core/demo" "${AGENTS_FIXTURE}/skills/extra/other" \ + "${AGENTS_FIXTURE}/skills/vault/notes" \ + "${AGENTS_FIXTURE}/agents" + # Every bundle a shipped persona asks for, so no run warns about one + # missing (each warning is a gum process). + local bundle + for bundle in $(sed -n 's/^skills=//p' "${PROJECT_ROOT}"/personas/* | tr ' ' '\n' | sort -u); do + mkdir -p "${AGENTS_FIXTURE}/skills/${bundle}/${bundle}-demo" + printf 'skill\n' > "${AGENTS_FIXTURE}/skills/${bundle}/${bundle}-demo/SKILL.md" + done + printf 'rules\n' > "${AGENTS_FIXTURE}/AGENTS.md" + printf 'agent\n' > "${AGENTS_FIXTURE}/agents/demo.md" + printf 'skill\n' > "${AGENTS_FIXTURE}/skills/core/demo/SKILL.md" + printf 'skill\n' > "${AGENTS_FIXTURE}/skills/extra/other/SKILL.md" + printf 'skill\n' > "${AGENTS_FIXTURE}/skills/vault/notes/SKILL.md" + git -C "${AGENTS_FIXTURE}" init -q + git -C "${AGENTS_FIXTURE}" add -A + git -C "${AGENTS_FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m fixture + + # No gum, as on CI: a gum process per warning costs more than the run. + PATH="$(path_without gum)" + export PATH + NO_STOW_PATH="$(path_without stow)" + export NO_STOW_PATH +} + +# PATH with each directory that holds NAME swapped for a directory of links +# to everything else in it. +path_without() { + local name="$1" dir farm out="" i=0 + local -a dirs=() + IFS=: read -r -a dirs <<< "${PATH}" + for dir in "${dirs[@]}"; do + if [[ -x "${dir}/${name}" ]]; then + i=$((i + 1)) + farm="${BATS_FILE_TMPDIR}/no-${name}-${i}" + mkdir -p "${farm}" + ln -s "${dir}"/* "${farm}/" + rm -f "${farm}/${name}" + dir="${farm}" + fi + out="${out:+${out}:}${dir}" + done + printf '%s\n' "${out}" +} + +# A private copy of the agents fixture for a test that changes it. +own_agents_fixture() { + cp -R "${AGENTS_FIXTURE}" "${TEST_TMPDIR}/agents-origin" + AGENTS_FIXTURE="${TEST_TMPDIR}/agents-origin" + export AGENTS_REPO_URL="file://${AGENTS_FIXTURE}" +} + +setup() { + setup_tmpdir + DEST="${TEST_TMPDIR}/home" + mkdir -p "${DEST}" + export AGENTS_REPO_URL="file://${AGENTS_FIXTURE}" + export DOTFILES_OS=darwin + # Keeps the machine's own Homebrew (and its tool skills) out of the tests. + export HOMEBREW_PREFIX="${TEST_TMPDIR}/brew" +} + +teardown() { + teardown_tmpdir +} + +run_link() { + run bash "${PROJECT_ROOT}/${LINK}" --destination "${DEST}" "$@" +} + +# A copy of the repo whose personas also hold "bare": the common layer only, +# no agents, no editors, on either OS. No real persona is that spare. +make_bare_repo() { + BARE="${TEST_TMPDIR}/bare-repo" + mkdir -p "${BARE}/scripts" + cp -R "${PROJECT_ROOT}/stow" "${PROJECT_ROOT}/editors" "${PROJECT_ROOT}/seed" "${PROJECT_ROOT}/personas" "${PROJECT_ROOT}/roles" "${BARE}/" + cp "${PROJECT_ROOT}/scripts/link.sh" "${PROJECT_ROOT}/scripts/common.sh" "${BARE}/scripts/" + printf 'os=darwin,linux\nlayers=common\nagents=no\nagent_apps=\nskills=\neditors=no\nbrew=\nbrew_optional=\nroles=\n' > "${BARE}/personas/bare" +} + +run_bare_link() { + run bash "${BARE}/scripts/link.sh" --repo "${BARE}" --destination "${DEST}" "$@" +} + +package_file() { + printf '%s/stow/%s\n' "${PROJECT_ROOT}" "$1" +} + +backup_of() { + find "${DEST}/.local/state/dotfiles/clobbered" -path "*/$1" 2>/dev/null | head -1 +} + +stat_mode() { + stat -f %Lp "$1" 2>/dev/null || stat -c %a "$1" +} + +# Every path under the destination except the agents checkout's .git: what +# git keeps there (locks, maintenance files) is git's doing, not link.sh's. +dest_paths() { + (cd "${DEST}" && find . -name .git -prune -o -print | LC_ALL=C sort) +} + +# Sets FIXTURE to a small dotfiles repo whose history held stow/common/.config/gone/rc +# and stow/linux/.config/tool/rc, and which now holds stow/common/.keep, a +# stow/extra package this platform does not use, and editors/code/settings.json. +make_fixture_repo() { + FIXTURE="${TEST_TMPDIR}/fixture" + mkdir -p "${FIXTURE}/stow/common/.config/gone" "${FIXTURE}/stow/linux/.config/tool" \ + "${FIXTURE}/stow/extra/.config/extra" "${FIXTURE}/editors/code" + printf 'keep\n' > "${FIXTURE}/stow/common/.keep" + printf 'gone\n' > "${FIXTURE}/stow/common/.config/gone/rc" + printf 'tool\n' > "${FIXTURE}/stow/linux/.config/tool/rc" + printf 'extra\n' > "${FIXTURE}/stow/extra/.config/extra/rc" + printf '{}\n' > "${FIXTURE}/editors/code/settings.json" + write_test_persona "${FIXTURE}" yes + git -C "${FIXTURE}" init -q + git -C "${FIXTURE}" add -A + git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m one + git -C "${FIXTURE}" rm -q -r stow/common/.config/gone stow/linux + git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m two +} + +run_fixture_link() { + run bash "${PROJECT_ROOT}/${LINK}" --repo "${FIXTURE}" --destination "${DEST}" "$@" +} + +# Runs link.sh with stdin on a pseudo-terminal and the answer typed there, +# since the layer prompt asks only on a terminal. Output is what the terminal +# showed, with its line endings normalized. +run_fixture_link_on_tty() { + local answer="$1" + shift + command -v python3 >/dev/null 2>&1 || skip "python3 not available" + run python3 - "${answer}" bash "${PROJECT_ROOT}/${LINK}" --repo "${FIXTURE}" --destination "${DEST}" "$@" <<'PY' +import os, pty, sys +answer, cmd = sys.argv[1], sys.argv[2:] +pid, fd = pty.fork() +if pid == 0: + os.environ["DOTFILES_PROMPT"] = "read" + os.execvp(cmd[0], cmd) +os.write(fd, (answer + "\n").encode()) +out = b"" +while True: + try: + chunk = os.read(fd, 4096) + except OSError: + break + if not chunk: + break + out += chunk +_, status = os.waitpid(pid, 0) +sys.stdout.write(out.decode(errors="replace").replace("\r\n", "\n")) +sys.exit(status >> 8 if os.WIFEXITED(status) else 1) +PY +} diff --git a/tests/link_ops.bats b/tests/link_ops.bats new file mode 100644 index 0000000..3b962de --- /dev/null +++ b/tests/link_ops.bats @@ -0,0 +1,905 @@ +#!/usr/bin/env bats +# Tests for scripts/link.sh add, rm and mv, personas, layers and git identity, +# against the same fixtures as link.bats (link_helper.bash). + +load test_helper +load link_helper + +# --------------------------------------------------------------------------- +# Removing files +# --------------------------------------------------------------------------- + +# The fixture repo with an executable tool and an app directory of two files +# committed in common, all linked into the destination. A function named as the +# argument changes the repo before the one link run. +make_linked_fixture() { + make_fixture_repo + mkdir -p "${FIXTURE}/stow/common/.local/bin" "${FIXTURE}/stow/common/.config/app/themes" + printf '#!/bin/sh\necho tool\n' > "${FIXTURE}/stow/common/.local/bin/tool" + chmod 750 "${FIXTURE}/stow/common/.local/bin/tool" + printf 'a\n' > "${FIXTURE}/stow/common/.config/app/config" + printf 'b\n' > "${FIXTURE}/stow/common/.config/app/themes/dark" + git -C "${FIXTURE}" add -A + git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m three + [[ -z "${1:-}" ]] || "$1" + run_fixture_link + assert_success + [ -L "${DEST}/.local/bin/tool" ] && [ -L "${DEST}/.config/app/themes/dark" ] +} + +fixture_tracks() { + git -C "${FIXTURE}" ls-files --error-unmatch -- "$1" >/dev/null 2>&1 +} + +# A bare "! cmd" never fails a Bats test, so the negation lives in a function. +fixture_untracks() { + ! fixture_tracks "$1" +} + +@test "rm keeps the file as a real copy with its mode and takes it out of the layer and the index" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_linked_fixture + + run_fixture_link rm "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "Kept ~/.local/bin/tool as a real file" + assert_output --partial "Removed stow/common/.local/bin/tool from the repo" + assert_output --partial "/fixture commit" + assert_output --partial "run df-rm on it before pulling" + + [ -f "${DEST}/.local/bin/tool" ] && [ ! -L "${DEST}/.local/bin/tool" ] + [ "$(cat "${DEST}/.local/bin/tool")" = "$(printf '#!/bin/sh\necho tool')" ] + [ "$(stat_mode "${DEST}/.local/bin/tool")" = 750 ] + [ ! -e "${FIXTURE}/stow/common/.local/bin/tool" ] + [ ! -e "${FIXTURE}/stow/common/.local" ] + [ -d "${FIXTURE}/stow/common" ] + fixture_untracks stow/common/.local/bin/tool + [ -z "$(find "${DEST}/.local/bin" -name '.df-rm.*')" ] + + # The next link run leaves the real file alone. + run_fixture_link + assert_success + [ -f "${DEST}/.local/bin/tool" ] && [ ! -L "${DEST}/.local/bin/tool" ] +} + +@test "rm takes a directory and removes every managed link in it" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_linked_fixture + printf 'mine\n' > "${DEST}/.config/app/local" + ln -s /etc/hosts "${DEST}/.config/app/hosts" + + cd "${DEST}/.config" + run_fixture_link rm app < /dev/null + assert_success + [ "$(cat "${DEST}/.config/app/config")" = a ] && [ ! -L "${DEST}/.config/app/config" ] + [ "$(cat "${DEST}/.config/app/themes/dark")" = b ] && [ ! -L "${DEST}/.config/app/themes/dark" ] + [ "$(readlink "${DEST}/.config/app/hosts")" = /etc/hosts ] + [ "$(cat "${DEST}/.config/app/local")" = mine ] + [ ! -e "${FIXTURE}/stow/common/.config/app" ] + fixture_untracks stow/common/.config/app/config + fixture_untracks stow/common/.config/app/themes/dark + [ -L "${DEST}/.local/bin/tool" ] + fixture_tracks stow/common/.local/bin/tool +} + +@test "rm refuses real files, foreign and non-repo links, and outside paths before changing anything" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_linked_fixture + mkdir -p "${FIXTURE}/seed/common/.config/seeded" "${DEST}/.config/seeded" + printf 'seed\n' > "${FIXTURE}/seed/common/.config/seeded/state" + printf 'seed\n' > "${DEST}/.config/seeded/state" + printf 'real\n' > "${DEST}/.real" + ln -s /etc/hosts "${DEST}/.hosts" + ln -s "${DEST}/.config/agents/AGENTS.md" "${DEST}/.agents-md" + printf 'outside\n' > "${TEST_TMPDIR}/outside" + local before + before="$(git -C "${FIXTURE}" status --porcelain)" + + for path in .real .hosts .agents-md .config/seeded/state "Library/Application Support/Code/User/settings.json"; do + run_fixture_link rm "${DEST}/.local/bin/tool" "${DEST}/${path}" < /dev/null + assert_failure + assert_output --partial "Not removing ~/${path}" + refute_output --partial "Kept" + done + run_fixture_link rm "${DEST}/.config/seeded/state" < /dev/null + assert_output --partial "a seed file" + run_fixture_link rm "${DEST}/.hosts" < /dev/null + assert_output --partial "links outside the repo" + run_fixture_link rm "${DEST}/.local/bin/tool" "${TEST_TMPDIR}/outside" < /dev/null + assert_failure + assert_output --partial "Not inside" + run_fixture_link rm "${DEST}" < /dev/null + assert_failure + assert_output --partial "Not inside" + run_fixture_link rm "${DEST}/.config/seeded" < /dev/null + assert_failure + assert_output --partial "No managed files to remove" + + [ -L "${DEST}/.local/bin/tool" ] + fixture_tracks stow/common/.local/bin/tool + [ "$(git -C "${FIXTURE}" status --porcelain)" = "${before}" ] + [ "$(readlink "${DEST}/.hosts")" = /etc/hosts ] + [ "$(cat "${DEST}/.real")" = real ] +} + +@test "rm dry run changes nothing" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_linked_fixture + + run_fixture_link --dry-run rm "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "Would replace the link ~/.local/bin/tool with a copy of stow/common/.local/bin/tool" + run_fixture_link --dry-run rm --delete "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "Would move ~/.local/bin/tool to ${DEST}/.local/state/dotfiles/cleanup/" + + [ -L "${DEST}/.local/bin/tool" ] + [ -f "${FIXTURE}/stow/common/.local/bin/tool" ] + fixture_tracks stow/common/.local/bin/tool + [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] + [ ! -e "${DEST}/.local/state/dotfiles/cleanup" ] +} + +@test "rm --delete moves the file to the cleanup folder instead of keeping it" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_linked_fixture + + run_fixture_link rm --delete "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "Moved ~/.local/bin/tool to ${DEST}/.local/state/dotfiles/cleanup/" + [ ! -e "${DEST}/.local/bin/tool" ] && [ ! -L "${DEST}/.local/bin/tool" ] + local archived + archived="$(find "${DEST}/.local/state/dotfiles/cleanup" -path '*.remove/.local/bin/tool')" + [ -n "${archived}" ] && [ ! -L "${archived}" ] + [ "$(stat_mode "${archived}")" = 750 ] + grep -q 'echo tool' "${archived}" + [ "$(stat_mode "${archived%/.local/bin/tool}")" = 700 ] + fixture_untracks stow/common/.local/bin/tool + + run_fixture_link --delete status + assert_failure + assert_output --partial "--delete only applies to rm" +} + +# --------------------------------------------------------------------------- +# Moving files between layers +# --------------------------------------------------------------------------- + +# The linked fixture with home linking common and extra, server also linking +# solo, a layer home does not, and work linking only common. +make_move_fixture() { + make_linked_fixture write_move_personas +} + +write_move_personas() { + local name layers + for name in home server work; do + case "${name}" in + home) layers="common extra" ;; + server) layers="common extra solo" ;; + work) layers="common" ;; + esac + printf 'os=darwin,linux\nlayers=%s\nagents=yes\nagent_apps=claude cursor codex\nskills=core\neditors=yes\nbrew=\nbrew_optional=\nroles=\n' \ + "${layers}" > "${FIXTURE}/personas/${name}" + done + git -C "${FIXTURE}" add -A + git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m four +} + +# Where a link in the destination points inside the fixture's stow/. +linked_package_path() { + local text + text="$(readlink "$1")" + printf '%s\n' "stow/${text##*/stow/}" +} + +@test "mv moves a file to another layer and keeps it linked" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + + run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "Moved stow/common/.local/bin/tool to stow/extra/.local/bin/tool; ~/.local/bin/tool links there" + assert_output --partial "/fixture commit" + assert_output --partial "one whose persona does not link 'extra' (linked by home, server) loses the file there" + + [ -L "${DEST}/.local/bin/tool" ] && [ -x "${DEST}/.local/bin/tool" ] + [ "$(cat "${DEST}/.local/bin/tool")" = "$(printf '#!/bin/sh\necho tool')" ] + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] + [[ "$(readlink "${DEST}/.local/bin/tool")" != /* ]] + [ ! -e "${FIXTURE}/stow/common/.local" ] + run git -C "${FIXTURE}" diff --cached -M --name-status + assert_output "$(printf 'R100\tstow/common/.local/bin/tool\tstow/extra/.local/bin/tool')" + [ -z "$(find "${DEST}/.local/bin" -name '.df-mv.*')" ] + + # The next link run keeps the new link and backs nothing up. + run_fixture_link + assert_success + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] + [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] +} + +@test "mv --platform moves into this OS's part, keeps it by default, and --no-platform moves out" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + + run_fixture_link mv --layer common --platform "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "every persona links 'common'" + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common.darwin/.local/bin/tool ] + + run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" < /dev/null + assert_success + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra.darwin/.local/bin/tool ] + [ ! -e "${FIXTURE}/stow/common.darwin/.local" ] + + run_fixture_link mv --layer extra --no-platform "${DEST}/.local/bin/tool" < /dev/null + assert_success + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] + [ "$(cat "${DEST}/.local/bin/tool")" = "$(printf '#!/bin/sh\necho tool')" ] + [ -f "${FIXTURE}/stow/extra/.local/bin/tool" ] + [ ! -e "${FIXTURE}/stow/extra.darwin/.local" ] +} + +@test "mv takes a directory and moves every managed link in it" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + printf 'mine\n' > "${DEST}/.config/app/local" + ln -s /etc/hosts "${DEST}/.config/app/hosts" + + cd "${DEST}/.config" + run_fixture_link mv --layer extra app < /dev/null + assert_success + [ "$(linked_package_path "${DEST}/.config/app/config")" = stow/extra/.config/app/config ] + [ "$(linked_package_path "${DEST}/.config/app/themes/dark")" = stow/extra/.config/app/themes/dark ] + [ "$(cat "${DEST}/.config/app/themes/dark")" = b ] + [ "$(readlink "${DEST}/.config/app/hosts")" = /etc/hosts ] + [ "$(cat "${DEST}/.config/app/local")" = mine ] + [ ! -e "${FIXTURE}/stow/common/.config/app" ] + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common/.local/bin/tool ] +} + +@test "mv asks for the layer on a terminal and needs one without it" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + + run_fixture_link mv "${DEST}/.local/bin/tool" < /dev/null + assert_failure + assert_output --partial "Name the layer with --layer (one of: common extra)" + + run_fixture_link_on_tty extra mv "${DEST}/.local/bin/tool" + assert_success + assert_output --partial "Layer to move the files to" + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/extra/.local/bin/tool ] +} + +@test "mv refuses bad paths, unlinked layers, the same place, and taken destinations with nothing changed" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + mkdir -p "${FIXTURE}/seed/common/.config/seeded" "${DEST}/.config/seeded" "${FIXTURE}/stow/extra/.config/app" + printf 'seed\n' > "${FIXTURE}/seed/common/.config/seeded/state" + printf 'seed\n' > "${DEST}/.config/seeded/state" + printf 'taken\n' > "${FIXTURE}/stow/extra/.config/app/config" + printf 'real\n' > "${DEST}/.real" + ln -s /etc/hosts "${DEST}/.hosts" + ln -s "${DEST}/.config/agents/AGENTS.md" "${DEST}/.agents-md" + printf 'outside\n' > "${TEST_TMPDIR}/outside" + local before + before="$(git -C "${FIXTURE}" status --porcelain)" + + for path in .real .hosts .agents-md .config/seeded/state "Library/Application Support/Code/User/settings.json"; do + run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" "${DEST}/${path}" < /dev/null + assert_failure + assert_output --partial "Not moving ~/${path}" + refute_output --partial "Moved" + done + run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" "${TEST_TMPDIR}/outside" < /dev/null + assert_failure + assert_output --partial "Not inside" + + run_fixture_link mv --layer solo "${DEST}/.local/bin/tool" < /dev/null + assert_failure + assert_output --partial "does not link layer 'solo' (linked by server)" + run_fixture_link mv --layer common "${DEST}/.local/bin/tool" < /dev/null + assert_failure + assert_output --partial "Not moving ~/.local/bin/tool: already in stow/common" + run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" "${DEST}/.config/app" < /dev/null + assert_failure + assert_output --partial "Not moving ~/.config/app/config: stow/extra/.config/app/config already exists" + run_fixture_link mv --layer extra --platform --no-platform "${DEST}/.local/bin/tool" < /dev/null + assert_failure + assert_output --partial "contradict" + run_fixture_link rm --no-platform "${DEST}/.local/bin/tool" < /dev/null + assert_failure + assert_output --partial "--no-platform only applies to mv" + + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common/.local/bin/tool ] + [ "$(linked_package_path "${DEST}/.config/app/themes/dark")" = stow/common/.config/app/themes/dark ] + [ "$(git -C "${FIXTURE}" status --porcelain)" = "${before}" ] + [ "$(cat "${FIXTURE}/stow/extra/.config/app/config")" = taken ] +} + +@test "mv dry run changes nothing" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + + run_fixture_link --dry-run mv --layer extra "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "Would move stow/common/.local/bin/tool to stow/extra/.local/bin/tool and relink ~/.local/bin/tool" + [ "$(linked_package_path "${DEST}/.local/bin/tool")" = stow/common/.local/bin/tool ] + [ ! -e "${FIXTURE}/stow/extra/.local" ] + [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] +} + +@test "mv lists references to the old repo path without editing them" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + mkdir -p "${FIXTURE}/tests" + printf 'first\nrun stow/common/.local/bin/tool here\n' > "${FIXTURE}/tests/check" + git -C "${FIXTURE}" add -A + git -C "${FIXTURE}" -c user.name=test -c user.email=test@example.com commit -q -m refs + + run_fixture_link mv --layer extra "${DEST}/.local/bin/tool" < /dev/null + assert_success + assert_output --partial "References to stow/common/.local/bin/tool, left for you to update:" + assert_output --partial " tests/check:2:run stow/common/.local/bin/tool here" + grep -q 'stow/common/.local/bin/tool' "${FIXTURE}/tests/check" +} + +@test "mv renames the repo file of a link renamed in ~ and keeps its layer" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + mv "${DEST}/.config/app/config" "${DEST}/.config/app/settings" + + run_fixture_link --dry-run mv "${DEST}/.config/app/settings" < /dev/null + assert_success + assert_output --partial "Would move stow/common/.config/app/config to stow/common/.config/app/settings and relink ~/.config/app/settings" + [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] + + run_fixture_link mv "${DEST}/.config/app/settings" < /dev/null + assert_success + assert_output --partial "Moved stow/common/.config/app/config to stow/common/.config/app/settings; ~/.config/app/settings links there" + assert_output --partial "Renamed 1 file(s) in the repo to match ~" + refute_output --partial "Layer to move" + [ "$(cat "${DEST}/.config/app/settings")" = a ] + [ "$(linked_package_path "${DEST}/.config/app/settings")" = stow/common/.config/app/settings ] + run git -C "${FIXTURE}" diff --cached -M --name-status + assert_output "$(printf 'R100\tstow/common/.config/app/config\tstow/common/.config/app/settings')" + + # The next link run keeps the new name and brings back nothing at the old one. + run_fixture_link + assert_success + [ ! -e "${DEST}/.config/app/config" ] && [ ! -L "${DEST}/.config/app/config" ] + [ "$(linked_package_path "${DEST}/.config/app/settings")" = stow/common/.config/app/settings ] + [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] +} + +@test "mv still asks for the layer of a renamed file on a terminal, defaulting to its own" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + mv "${DEST}/.config/app/config" "${DEST}/.config/app/settings" + mv "${DEST}/.config/app/themes/dark" "${DEST}/.config/app/themes/night" + + run_fixture_link_on_tty "" mv "${DEST}/.config/app/settings" + assert_success + assert_output --partial "Layer to move the files to" + assert_output --partial "Choice [common]" + assert_output --partial "Renamed 1 file(s) in the repo to match ~" + [ "$(linked_package_path "${DEST}/.config/app/settings")" = stow/common/.config/app/settings ] + + run_fixture_link_on_tty extra mv "${DEST}/.config/app/themes/night" + assert_success + assert_output --partial "Moved 1 file(s) to layer extra" + [ "$(linked_package_path "${DEST}/.config/app/themes/night")" = stow/extra/.config/app/themes/night ] + [ "$(cat "${DEST}/.config/app/themes/night")" = b ] +} + +@test "mv follows a link moved to another depth, whose old text no longer resolves" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + mv "${DEST}/.local/bin/tool" "${DEST}/.config/tool" + [ ! -e "${DEST}/.config/tool" ] + + run_fixture_link mv "${DEST}/.config/tool" < /dev/null + assert_success + [ -x "${DEST}/.config/tool" ] + [ "$(cat "${DEST}/.config/tool")" = "$(printf '#!/bin/sh\necho tool')" ] + [ "$(linked_package_path "${DEST}/.config/tool")" = stow/common/.config/tool ] + [[ "$(readlink "${DEST}/.config/tool")" != /* ]] + [ ! -e "${FIXTURE}/stow/common/.local" ] +} + +@test "mv follows a renamed directory, and --layer changes the layer too" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + mv "${DEST}/.config/app" "${DEST}/.config/app2" + + run_fixture_link mv --layer extra "${DEST}/.config/app2" < /dev/null + assert_success + [ "$(linked_package_path "${DEST}/.config/app2/config")" = stow/extra/.config/app2/config ] + [ "$(linked_package_path "${DEST}/.config/app2/themes/dark")" = stow/extra/.config/app2/themes/dark ] + [ "$(cat "${DEST}/.config/app2/themes/dark")" = b ] + [ ! -e "${FIXTURE}/stow/common/.config/app" ] +} + +@test "mv refuses a rename that add would refuse, with nothing changed" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_move_fixture + mv "${DEST}/.config/app/config" "${DEST}/.config/app/config.local" + + run_fixture_link mv "${DEST}/.config/app/config.local" < /dev/null + assert_failure + assert_output --partial "Not moving ~/.config/app/config.local: a .local file holds this machine's overrides" + [ -z "$(git -C "${FIXTURE}" status --porcelain)" ] + [ -f "${FIXTURE}/stow/common/.config/app/config" ] +} + +# --------------------------------------------------------------------------- +# Personas +# --------------------------------------------------------------------------- + +persona_state() { + cat "${DEST}/.local/state/dotfiles/persona" +} + +@test "lists the personas this OS can use" { + run_link personas + assert_success + assert_line "home" + assert_line "work" + DOTFILES_OS=linux run_link personas + assert_success + refute_line "home" + assert_line "server" + assert_line "vps" +} + +@test "uses the OS default until a persona is chosen, and says so" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + run_link persona + assert_output "home default" + DOTFILES_OS=linux run_link persona + assert_output "server default" + + run_link + assert_success + assert_output --partial "No persona chosen" + [ ! -e "${DEST}/.local/state/dotfiles/persona" ] +} + +@test "a chosen persona is saved privately and reused by later runs" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + run_link --persona work + assert_success + assert_output --partial "Saved persona work" + [ "$(persona_state)" = "work" ] + [ "$(stat_mode "${DEST}/.local/state/dotfiles")" = "700" ] + + run_link persona + assert_output "work saved" + run_link + assert_success + refute_output --partial "No persona chosen" + refute_output --partial "Saved persona" +} + +@test "the persona from the environment is used and saved like the flag" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + DOTFILES_PERSONA=server run_link persona + assert_output "server env" + DOTFILES_PERSONA=server run_link + assert_success + [ "$(persona_state)" = "server" ] + # The flag wins over the environment. + DOTFILES_PERSONA=server run_link --persona work persona + assert_output "work flag" +} + +@test "a dry run never saves the persona" { + run_link --dry-run --persona work + assert_success + [ ! -e "${DEST}/.local/state/dotfiles/persona" ] +} + +@test "a persona without agents or editors links only the shared files" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + make_bare_repo + run_bare_link --persona bare + assert_success + [ -L "${DEST}/.zshenv" ] + [ ! -e "${DEST}/.config/agents" ] + [ ! -e "${DEST}/.cursor/AGENTS.md" ] + [ ! -e "${DEST}/Library/Application Support/Code" ] + + run_bare_link managed + refute_line "${DEST}/.config/agents" + refute_line --partial "Application Support" + run_bare_link status + assert_success + assert_output "unset ${DEST}/.config/git/config.local" +} + +@test "switching to a persona without editors removes the editor links" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + run_link --persona home + assert_success + [ -L "${DEST}/Library/Application Support/Code/User/settings.json" ] + + run_link --persona server + assert_success + assert_output --partial "Removed stale link ${DEST}/Library/Application Support/Code/User/settings.json" + [ ! -e "${DEST}/Library/Application Support/Code/User/settings.json" ] + [ -L "${DEST}/.zshenv" ] + [ "$(persona_state)" = "server" ] +} + +@test "rejects an unknown persona or one for another OS before changing anything" { + run_link --persona nope + assert_failure 2 + assert_output --partial "Unknown persona 'nope'" + run_link --persona ../home + assert_failure 2 + DOTFILES_OS=linux run_link --persona home + assert_failure 2 + assert_output --partial "Persona 'home' is not for linux" + [ -z "$(ls -A "${DEST}")" ] +} + +@test "a saved persona that no longer exists fails clearly" { + mkdir -p "${DEST}/.local/state/dotfiles" + printf 'retired\n' > "${DEST}/.local/state/dotfiles/persona" + run_link status + assert_failure 2 + assert_output --partial "Unknown persona 'retired'" +} + +# --------------------------------------------------------------------------- +# Layers +# --------------------------------------------------------------------------- + +@test "each persona links its own layers" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + run_link --persona home + assert_success + assert test -L "${DEST}/.config/ghostty/config.ghostty" + [ -L "${DEST}/.local/bin/sshkey" ] + [ -L "${DEST}/.local/bin/df-deploy" ] + [ ! -e "${DEST}/.local/bin/ph-update" ] + + # A work Mac gets the shared tools but nothing personal or Pi-hole. + DEST="${TEST_TMPDIR}/work-home" + run_link --persona work + assert_success + [ -L "${DEST}/.local/bin/sshkey" ] + for path in .local/bin/cf-cert .local/bin/df-deploy .local/bin/ts-test \ + .local/bin/ph-update .local/bin/ph-padd; do + [ ! -e "${DEST}/${path}" ] + done + + # The Pi-hole tools come with the pihole role, not the server persona. + DEST="${TEST_TMPDIR}/pi-home" + DOTFILES_OS=linux run_link --persona server + assert_success + [ ! -e "${DEST}/.local/bin/ph-update" ] + [ -L "${DEST}/.local/bin/sshkey" ] + DOTFILES_OS=linux DOTFILES_INSTALLS=pihole run_link --persona server + assert_success + [ -L "${DEST}/.local/bin/ph-update" ] + [ -L "${DEST}/.local/bin/ph-backup" ] + [ -L "${DEST}/.local/bin/sshkey" ] + [ -L "${DEST}/.local/bin/cf-cert" ] + [ -L "${DEST}/.local/bin/ts-test" ] + [ ! -e "${DEST}/.config/ghostty" ] + [ ! -e "${DEST}/.config/1Password" ] + + # A Mac server gets the shared tools but not the Linux-only Pi-hole ones. + DEST="${TEST_TMPDIR}/mini-home" + run_link --persona server + assert_success + [ -L "${DEST}/.local/bin/os-doctor" ] + [ ! -e "${DEST}/.local/bin/ph-update" ] + + DEST="${TEST_TMPDIR}/vps-home" + DOTFILES_OS=linux run_link --persona vps + assert_success + [ -L "${DEST}/.zshenv" ] + [ -L "${DEST}/.local/bin/cf-cert" ] + [ ! -e "${DEST}/.local/bin/ph-update" ] +} + +# Prints a git setting as git resolves it with DEST as the home directory. +dest_git_config() { + HOME="${DEST}" XDG_CONFIG_HOME="${DEST}/.config" GIT_CONFIG_NOSYSTEM=1 \ + git config --global --includes --get "$1" +} + +@test "git and ssh pick up the settings of the persona's layers" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + run_link --persona work + assert_success + run dest_git_config core.editor + assert_output code + run dest_git_config gpg.format + assert_output ssh + [ -L "${DEST}/.ssh/config.d/1password" ] + + # config.local overrides a layer setting. + printf '[core]\n editor = vim\n' > "${DEST}/.config/git/config.local" + run dest_git_config core.editor + assert_output vim + + DEST="${TEST_TMPDIR}/pi-home" + DOTFILES_OS=linux run_link --persona server + assert_success + run dest_git_config core.pager + assert_output delta + run dest_git_config merge.tool + assert_output vimdiff + [ ! -e "${DEST}/.ssh/config.d" ] + + DEST="${TEST_TMPDIR}/vps-home" + DOTFILES_OS=linux run_link --persona vps + assert_success + run dest_git_config core.pager + assert_failure +} + +@test "only the personal layer brings a git identity and the gh login" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + run_link --persona home + assert_success + refute_output --partial "No git identity" + [ -f "${DEST}/.config/gh/hosts.yml" ] + run dest_git_config user.email + assert_output --partial smithbr@users.noreply.github.com + + # config.local still wins over the personal identity. + printf '[user]\n email = me@example.com\n' > "${DEST}/.config/git/config.local" + run dest_git_config user.email + assert_output me@example.com + + for persona in work; do + DEST="${TEST_TMPDIR}/${persona}-home" + run_link --persona "${persona}" + assert_success + run dest_git_config user.email + assert_failure + [ ! -e "${DEST}/.config/gh/hosts.yml" ] + run_link --persona "${persona}" + assert_output --partial "No git identity for persona '${persona}'" + run_link --persona "${persona}" status + assert_line "unset ${DEST}/.config/git/config.local" + + printf '[user]\n email = me@work.example\n' > "${DEST}/.config/git/config.local" + run_link --persona "${persona}" + refute_output --partial "No git identity" + done +} + +@test "a [user] in ~/.gitconfig is reported because it overrides config.local" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + printf '[user]\n email = old@example.com\n' > "${DEST}/.gitconfig" + run_link --persona work + assert_success + assert_output --partial "${DEST}/.gitconfig sets [user]" + assert_output --partial "then archive it with df-doctor" + run_link --persona work status + assert_line "shadowed ${DEST}/.gitconfig" + [ "$(cat "${DEST}/.gitconfig")" = "$(printf '[user]\n email = old@example.com')" ] + + printf '[filter "lfs"]\n required = true\n' > "${DEST}/.gitconfig" + run_link --persona work status + refute_line "shadowed ${DEST}/.gitconfig" +} + +@test "status names real files a layer the persona does not link would own" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + mkdir -p "${DEST}/.config/git" "${DEST}/.local/bin" + printf 'old copy\n' > "${DEST}/.local/bin/cf-cert" + printf '[user]\n email = me@work.example\n' > "${DEST}/.config/git/config.local" + run_link --persona work + assert_success + + run_link --persona work status + assert_success + assert_output "unlinked ${DEST}/.local/bin/cf-cert" + [ "$(cat "${DEST}/.local/bin/cf-cert")" = 'old copy' ] + + # A whole folder only an unlinked layer would own is named once, not per + # file. A fresh home: here work has linked that folder already. + local server_home="${TEST_TMPDIR}/server-home" + mkdir -p "${server_home}/.config/ghostty" + printf 'font-size = 14\n' > "${server_home}/.config/ghostty/config" + DEST="${server_home}" run_link --persona server status + assert_success + assert_line "unlinked ${server_home}/.config/ghostty" + refute_output --partial "ghostty/config" + [ "$(cat "${server_home}/.config/ghostty/config")" = 'font-size = 14' ] + + # A persona that links the layer does not report its folder. + rm "${DEST}/.local/bin/cf-cert" + run_link --persona home + assert_success + run_link --persona home status + refute_output --partial unlinked +} + +# Links a run of the single-layer layout left: every tool under stow/common. +plant_single_layer_links() { + local name + mkdir -p "${DEST}/.local/bin" "${DEST}/.config/ghostty" + for name in sshkey ph-update ph-backup; do + ln -s "${PROJECT_ROOT}/stow/common/.local/bin/${name}" "${DEST}/.local/bin/${name}" + done + # A path the repo holds today: a file only in history is stale only when + # git log can see it, and CI's checkout is shallow. + ln -s "${PROJECT_ROOT}/stow/common/.config/ghostty/config.ghostty" "${DEST}/.config/ghostty/config.ghostty" +} + +@test "moving to layers relinks what the persona keeps and removes the rest" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + plant_single_layer_links + run_link --persona home + assert_success + refute_output --partial "Backed up" + [ "$(readlink "${DEST}/.local/bin/sshkey")" = "../../.dotfiles/stow/tools/.local/bin/sshkey" ] \ + || [ "$(cd "${DEST}/.local/bin" && cd -P "$(dirname "$(readlink sshkey)")" && pwd)" = "${REPO_ROOT}/stow/tools/.local/bin" ] + [ "$(cd "${DEST}/.config/ghostty" && cd -P "$(dirname "$(readlink config.ghostty)")" && pwd)" = "${REPO_ROOT}/stow/desktop/.config/ghostty" ] + [ ! -L "${DEST}/.local/bin/ph-update" ] + [ ! -L "${DEST}/.local/bin/ph-backup" ] + [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] + + DEST="${TEST_TMPDIR}/pi-home" + plant_single_layer_links + DOTFILES_OS=linux DOTFILES_INSTALLS=pihole run_link --persona server + assert_success + refute_output --partial "Backed up" + [ -e "${DEST}/.local/bin/ph-update" ] + [ -e "${DEST}/.local/bin/ph-backup" ] + [ ! -L "${DEST}/.config/ghostty/config.ghostty" ] + [ ! -e "${DEST}/.local/state/dotfiles/clobbered" ] + DOTFILES_OS=linux run_link status + assert_success + assert_output "" +} + +@test "installs prints what each persona installs, with the optional installs on by default but Holeberry off" { + run_link --persona work installs + assert_success + assert_output "brew=core work +brew_optional=macos +harden=no +installs=claude-code +installs_source=default +install=claude-code claude-code tool_installed claude" + run_link --persona home installs + assert_line "brew=core home" + assert_line "installs=claude-code" + # Picked by name, Holeberry is still installed. + DOTFILES_INSTALLS="holeberry claude-code" run_link --persona work installs + assert_success + assert_line "installs=holeberry claude-code" + assert_line "installs_source=env" + DOTFILES_OS=linux run_link --persona server installs + assert_line "installs=docker tailscale claude-code" + assert_line "brew=core" + DOTFILES_OS=linux run_link --persona vps installs + assert_line "brew=core" + assert_line "brew_optional=" + assert_line "harden=yes" + assert_line "installs=docker tailscale claude-code" +} + +@test "a persona's harden setting must be yes or no" { + local repo="${TEST_TMPDIR}/harden-repo" + mkdir -p "${repo}/stow/common" "${repo}/personas" + printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nharden=maybe\n' > "${repo}/personas/home" + cp "${repo}/personas/home" "${repo}/personas/server" + run bash "${PROJECT_ROOT}/scripts/link.sh" --destination "${DEST}" --repo "${repo}" installs + assert_failure 2 + assert_output --partial "harden must be yes or no, not 'maybe'" +} + +@test "offered lists the optional installs for this OS, then the persona's roles, with each detect command" { + local hb_detect='[[ -d "${HOLEBERRY_APPS_DIR:-/Applications}/Holeberry.app" ]]' + run_link --persona work offered + assert_success + assert_output "holeberry optional ${hb_detect} +claude-code default tool_installed claude" + run_link --persona server offered + assert_output "holeberry optional ${hb_detect} +claude-code default tool_installed claude" + DOTFILES_OS=linux run_link --persona server offered + assert_line --index 0 "docker default tool_installed docker" + assert_line --index 1 "tailscale default tool_installed tailscale" + assert_line --index 2 "claude-code default tool_installed claude" + assert_line "pihole optional [[ -x /usr/bin/pihole-FTL && -x /usr/local/bin/pihole && -x /usr/sbin/unbound && -f /etc/unbound/unbound.conf.d/pi-hole.conf ]]" + # A vps is asked whether it is a Pi-hole too. + DOTFILES_OS=linux run_link --persona vps offered + assert_line --index 0 "docker default tool_installed docker" + assert_line --partial "pihole optional " +} + +@test "optional installs are the setup scripts with a detect line, the OS directory first, on every persona" { + make_bare_repo + mkdir -p "${BARE}/scripts/bootstrap/linux" "${BARE}/scripts/bootstrap/macos" + printf '#!/usr/bin/env bash\n# detect: command -v zed\n' > "${BARE}/scripts/bootstrap/zed.sh" + printf '#!/usr/bin/env bash\n# detect: command -v docker\n' > "${BARE}/scripts/bootstrap/linux/docker.sh" + printf '#!/usr/bin/env bash\n# detect: [[ -d /Applications/Docker.app ]]\n' > "${BARE}/scripts/bootstrap/macos/docker.sh" + # A script without a detect line (an OS bootstrap, a role's setup) is not one. + printf '#!/usr/bin/env bash\necho plain\n' > "${BARE}/scripts/bootstrap/plain.sh" + DOTFILES_OS=linux run_bare_link --persona bare offered + assert_success + assert_output "docker default command -v docker +zed default command -v zed" + DOTFILES_OS=darwin run_bare_link --persona bare offered + assert_line --index 0 "docker default [[ -d /Applications/Docker.app ]]" + DOTFILES_OS=linux run_bare_link --persona bare installs + assert_line "installs=docker zed" + assert_line "installs_source=default" + assert_line "install=zed zed command -v zed" + # A role may require an optional install, which then joins before it. + printf 'os=darwin,linux\nrequires=zed\n' > "${BARE}/roles/needy" + printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nroles_optional=needy\n' > "${BARE}/personas/bare" + DOTFILES_OS=linux DOTFILES_INSTALLS="needy" run_bare_link --persona bare installs + assert_success + assert_line "installs=zed needy" +} + +@test "chosen installs come from DOTFILES_INSTALLS, in run order with what they require, and add their layers" { + DOTFILES_OS=linux DOTFILES_INSTALLS="pihole" run_link --persona server installs + assert_success + assert_line "installs=tailscale pihole" + assert_line "installs_source=env" + assert_line "install=pihole pihole [[ -x /usr/bin/pihole-FTL && -x /usr/local/bin/pihole && -x /usr/sbin/unbound && -f /etc/unbound/unbound.conf.d/pi-hole.conf ]]" + # The pihole role brings the pihole layer; without it a Linux server has no ph-* tools. + DOTFILES_OS=linux DOTFILES_INSTALLS="pihole" run_link --persona server managed + assert_line "${DEST}/.local/bin/ph-update" + DOTFILES_OS=linux DOTFILES_INSTALLS="" run_link --persona server managed + refute_line "${DEST}/.local/bin/ph-update" + + DOTFILES_OS=darwin DOTFILES_INSTALLS="pihole" run_link --persona server installs + assert_failure 2 + assert_output --partial "does not offer install 'pihole' on darwin" + DOTFILES_OS=linux DOTFILES_INSTALLS="docker" run_link --persona server installs + assert_line "installs=docker" +} + +@test "chosen installs are saved with the persona and a persona switch drops the ones it does not offer" { + command -v stow >/dev/null 2>&1 || skip "stow not installed" + DOTFILES_OS=linux DOTFILES_INSTALLS="pihole docker" run_link --persona server + assert_success + assert_output --partial "Saved installs tailscale pihole docker" + [ "$(cat "${DEST}/.local/state/dotfiles/installs")" = "tailscale pihole docker" ] + [ -L "${DEST}/.local/bin/ph-update" ] + + DOTFILES_OS=linux run_link installs + assert_line "installs=tailscale pihole docker" + assert_line "installs_source=saved" + + # Every Linux persona offers pihole; the same persona on macOS does not. + DOTFILES_OS=darwin run_link --persona server + assert_success + assert_output --partial "does not offer the saved install 'pihole'" + [ ! -e "${DEST}/.local/bin/ph-update" ] + DOTFILES_OS=darwin DOTFILES_INSTALLS="" run_link --persona server + assert_output --partial "Saved installs none" + [ "$(cat "${DEST}/.local/state/dotfiles/installs")" = "" ] + + DOTFILES_OS=linux DOTFILES_INSTALLS="docker" run_link --dry-run --persona server + assert_success + [ "$(cat "${DEST}/.local/state/dotfiles/installs")" = "" ] +} + +@test "a persona naming a role the repo lacks, or a role requiring one, fails clearly" { + make_bare_repo + printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nroles=missing\n' > "${BARE}/personas/bare" + run_bare_link --persona bare installs + assert_failure 2 + assert_output --partial "no roles/missing for roles" + printf 'os=darwin,linux\nrequires=nothing\n' > "${BARE}/roles/odd" + printf 'os=darwin,linux\nlayers=common\nagents=no\neditors=no\nroles_optional=odd\n' > "${BARE}/personas/bare" + DOTFILES_INSTALLS=odd run_bare_link --persona bare installs + assert_failure 2 + assert_output --partial "roles/odd requires 'nothing', which neither roles/ nor scripts/bootstrap/ has" +}