aboutsummaryrefslogtreecommitdiff
path: root/tests/unit
diff options
context:
space:
mode:
Diffstat (limited to 'tests/unit')
-rw-r--r--tests/unit/test_archangel.bats95
-rw-r--r--tests/unit/test_btrfs.bats35
-rw-r--r--tests/unit/test_build_aur.bats11
-rw-r--r--tests/unit/test_common.bats70
-rw-r--r--tests/unit/test_config.bats58
-rw-r--r--tests/unit/test_test_install.bats50
6 files changed, 315 insertions, 4 deletions
diff --git a/tests/unit/test_archangel.bats b/tests/unit/test_archangel.bats
index 645b6e6..983bfd2 100644
--- a/tests/unit/test_archangel.bats
+++ b/tests/unit/test_archangel.bats
@@ -401,3 +401,98 @@ setup() {
run network_available
[ "$status" -eq 0 ]
}
+
+#############################
+# configure_zfs_keyfile
+#############################
+# Encrypted ZFS installs prompt for the same passphrase twice:
+# ZFSBootMenu unlocks the pool to read the kernel and initramfs, then
+# kexecs, and the key doesn't survive kexec — so the booted initramfs
+# re-imports the pool, finds keylocation=prompt, and asks again.
+#
+# configure_zfs_keyfile closes the second prompt the same way the Btrfs
+# path already closes its LUKS equivalent: write the passphrase to a
+# keyfile inside the encrypted root, point the encryption root at it,
+# and bake it into the initramfs via FILES=. ZFSBootMenu can't read a
+# file inside a dataset it hasn't unlocked yet, so it still prompts
+# once — that surviving prompt is the intended behavior, not a bug.
+#
+# zfs is the stubbed system boundary. The keyfile write, its
+# permissions, and the FILES= wiring are exercised for real.
+
+zfs_keyfile_fixture() {
+ TEST_ROOT=$(mktemp -d)
+ MNTPOINT="$TEST_ROOT"
+ ZFS_ARGS_LOG="$TEST_ROOT/zfs-args"
+ mkdir -p "$MNTPOINT/etc"
+ printf '%s\n' 'FILES=()' > "$MNTPOINT/etc/mkinitcpio.conf"
+ zfs() { echo "$*" >> "$ZFS_ARGS_LOG"; return 0; }
+}
+
+@test "configure_zfs_keyfile writes the passphrase with no trailing newline" {
+ zfs_keyfile_fixture
+ configure_zfs_keyfile "correct horse" zroot
+ # The keyfile lands mode 000, which locks out the owner too — only root
+ # bypasses that, and these tests don't run as root. Restore read access to
+ # inspect the content; the mode itself is asserted separately below.
+ chmod u+r "$MNTPOINT/etc/zfs/zroot.key"
+ # A trailing newline would become part of the passphrase ZFS reads back,
+ # so the key would never match what's typed at the ZBM prompt. 13 bytes,
+ # not 14: no terminator.
+ [ "$(wc -c < "$MNTPOINT/etc/zfs/zroot.key")" -eq 13 ]
+ [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = "correct horse" ]
+ rm -rf "$TEST_ROOT"
+}
+
+@test "configure_zfs_keyfile points the encryption root at the keyfile" {
+ zfs_keyfile_fixture
+ configure_zfs_keyfile testpass zroot
+ grep -qE '^set +keylocation=file:///etc/zfs/zroot\.key +zroot$' "$ZFS_ARGS_LOG"
+ rm -rf "$TEST_ROOT"
+}
+
+@test "configure_zfs_keyfile changes the location without rekeying the pool" {
+ zfs_keyfile_fixture
+ configure_zfs_keyfile testpass zroot
+ # keylocation is settable with plain `zfs set` (zfsprops(7)), and
+ # keyformat is already passphrase from pool creation. Reaching for
+ # `zfs change-key` here would rekey the pool and prompt for new key
+ # material mid-install — and losing keyformat=passphrase would leave
+ # ZFSBootMenu with no way to accept a typed passphrase at all.
+ ! grep -qF 'change-key' "$ZFS_ARGS_LOG"
+ rm -rf "$TEST_ROOT"
+}
+
+@test "configure_zfs_keyfile bakes the keyfile into the initramfs" {
+ zfs_keyfile_fixture
+ configure_zfs_keyfile testpass zroot
+ grep -qF 'FILES=(/etc/zfs/zroot.key)' "$MNTPOINT/etc/mkinitcpio.conf"
+ rm -rf "$TEST_ROOT"
+}
+
+@test "configure_zfs_keyfile leaves the keyfile unreadable to other users" {
+ zfs_keyfile_fixture
+ configure_zfs_keyfile testpass zroot
+ # Protected at rest by the encrypted dataset, but a stray mode 644
+ # would expose it to any local user on the running system.
+ [ "$(stat -c '%a' "$MNTPOINT/etc/zfs/zroot.key")" -eq 0 ]
+ rm -rf "$TEST_ROOT"
+}
+
+@test "configure_zfs_keyfile preserves a passphrase containing shell metacharacters" {
+ zfs_keyfile_fixture
+ configure_zfs_keyfile 'a$b "c" \d*' zroot
+ chmod u+r "$MNTPOINT/etc/zfs/zroot.key"
+ [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = 'a$b "c" \d*' ]
+ rm -rf "$TEST_ROOT"
+}
+
+@test "configure_zfs_keyfile aborts when the key change fails" {
+ zfs_keyfile_fixture
+ zfs() { return 1; }
+ run configure_zfs_keyfile testpass zroot
+ # Silently continuing would ship an initramfs whose keyfile doesn't
+ # match the pool, turning one prompt into an unbootable system.
+ [ "$status" -eq 1 ]
+ rm -rf "$TEST_ROOT"
+}
diff --git a/tests/unit/test_btrfs.bats b/tests/unit/test_btrfs.bats
index 890bba2..15bf141 100644
--- a/tests/unit/test_btrfs.bats
+++ b/tests/unit/test_btrfs.bats
@@ -54,3 +54,38 @@ setup() {
[ "$status" -eq 0 ]
[ -z "$output" ]
}
+
+#############################
+# parse_btrfs_subvol_opts
+#############################
+# Composes the mount-option string for one subvolume from the shared
+# BTRFS_OPTS plus the per-subvol extra flags. Pure string transform,
+# shared by mount_btrfs_subvolumes and generate_btrfs_fstab. BTRFS_OPTS
+# is set at the top of btrfs.sh (sourced in setup), so these pin behavior
+# against the real default option string.
+
+@test "parse_btrfs_subvol_opts: no extra flags keeps the default opts" {
+ run parse_btrfs_subvol_opts "@home" ""
+ [ "$status" -eq 0 ]
+ [ "$output" = "subvol=@home,noatime,compress=zstd,space_cache=v2,discard=async" ]
+}
+
+@test "parse_btrfs_subvol_opts: compress=no drops compress=zstd" {
+ run parse_btrfs_subvol_opts "@media" "compress=no"
+ [ "$output" = "subvol=@media,noatime,space_cache=v2,discard=async" ]
+}
+
+@test "parse_btrfs_subvol_opts: nodatacow adds nodatacow and drops compress=zstd" {
+ run parse_btrfs_subvol_opts "@vms" "nodatacow"
+ [ "$output" = "subvol=@vms,noatime,space_cache=v2,discard=async,nodatacow" ]
+}
+
+@test "parse_btrfs_subvol_opts: nosuid adds nosuid,nodev and keeps compression" {
+ run parse_btrfs_subvol_opts "@tmp" "nosuid"
+ [ "$output" = "subvol=@tmp,noatime,compress=zstd,space_cache=v2,discard=async,nosuid,nodev" ]
+}
+
+@test "parse_btrfs_subvol_opts: nodatacow and nosuid combine" {
+ run parse_btrfs_subvol_opts "@x" "nodatacow,nosuid"
+ [ "$output" = "subvol=@x,noatime,space_cache=v2,discard=async,nodatacow,nosuid,nodev" ]
+}
diff --git a/tests/unit/test_build_aur.bats b/tests/unit/test_build_aur.bats
index 4da66fe..360f2b7 100644
--- a/tests/unit/test_build_aur.bats
+++ b/tests/unit/test_build_aur.bats
@@ -15,12 +15,12 @@ setup() {
# aur_v1_packages — single source of truth for the v1 build set
#############################
-@test "aur_v1_packages lists the nine audited v1 packages" {
+@test "aur_v1_packages lists the eight audited v1 packages" {
run aur_v1_packages
[ "$status" -eq 0 ]
- [ "$(echo "$output" | wc -l)" -eq 9 ]
+ [ "$(echo "$output" | wc -l)" -eq 8 ]
for pkg in downgrade yay informant zrepl pacman-cleanup-hook \
- sanoid zfs-auto-snapshot topgrade ventoy-bin; do
+ zfs-auto-snapshot topgrade ventoy-bin; do
[[ "$output" == *"$pkg"* ]]
done
}
@@ -28,9 +28,12 @@ setup() {
@test "aur_v1_packages excludes the vNext-deferred packages" {
run aur_v1_packages
[ "$status" -eq 0 ]
- # paru (second helper) and mkinitcpio-firmware (AUR-of-AUR deps) are vNext
+ # paru (second helper), mkinitcpio-firmware, and sanoid all pull AUR-of-AUR
+ # deps (sanoid needs perl-config-inifiles, AUR-only) — deferred to the
+ # vNext helper-driven dependency-resolution work.
[[ "$output" != *"paru"* ]]
[[ "$output" != *"mkinitcpio-firmware"* ]]
+ [[ "$output" != *"sanoid"* ]]
}
@test "aur_v1_packages emits one package per line" {
diff --git a/tests/unit/test_common.bats b/tests/unit/test_common.bats
index 6f9d1b1..c76b6a4 100644
--- a/tests/unit/test_common.bats
+++ b/tests/unit/test_common.bats
@@ -666,6 +666,26 @@ Boot0001* ZFSBootMenu"
rm -f "$f"
}
+@test "strip_repo_stanza preserves the target file mode (no 0600 clobber)" {
+ local f
+ f=$(mktemp)
+ printf '%s\n' '[core]' '[aur]' 'Server = file:///usr/share/aur-packages' '[extra]' > "$f"
+ chmod 644 "$f"
+ strip_repo_stanza aur "$f"
+ [ "$(stat -c %a "$f")" = "644" ]
+ rm -f "$f"
+}
+
+@test "strip_repo_stanza preserves a non-default file mode" {
+ local f
+ f=$(mktemp)
+ printf '%s\n' '[core]' '[aur]' 'Server = x' '[extra]' > "$f"
+ chmod 640 "$f"
+ strip_repo_stanza aur "$f"
+ [ "$(stat -c %a "$f")" = "640" ]
+ rm -f "$f"
+}
+
#############################
# aur_repo_available
#############################
@@ -720,3 +740,53 @@ Boot0001* ZFSBootMenu"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
+
+#############################
+# aur_zfs_only_packages / filter_aur_for_fs
+#############################
+
+@test "aur_zfs_only_packages lists zfs-auto-snapshot and zrepl" {
+ run aur_zfs_only_packages
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"zfs-auto-snapshot"* ]]
+ [[ "$output" == *"zrepl"* ]]
+}
+
+@test "filter_aur_for_fs zfs keeps every package including zfs-only tooling" {
+ run filter_aur_for_fs zfs downgrade yay zrepl zfs-auto-snapshot topgrade
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 5 ]
+ [[ "$output" == *"zfs-auto-snapshot"* ]]
+ [[ "$output" == *"zrepl"* ]]
+ [[ "$output" == *"yay"* ]]
+}
+
+@test "filter_aur_for_fs btrfs drops zfs-only tooling, keeps the rest" {
+ run filter_aur_for_fs btrfs downgrade yay zrepl zfs-auto-snapshot topgrade
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 3 ]
+ [[ "$output" != *"zfs-auto-snapshot"* ]]
+ [[ "$output" != *"zrepl"* ]]
+ [[ "$output" == *"downgrade"* ]]
+ [[ "$output" == *"yay"* ]]
+ [[ "$output" == *"topgrade"* ]]
+}
+
+@test "filter_aur_for_fs btrfs with only zfs-only tooling prints nothing" {
+ run filter_aur_for_fs btrfs zfs-auto-snapshot zrepl
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "filter_aur_for_fs with no package arguments prints nothing" {
+ run filter_aur_for_fs btrfs
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "filter_aur_for_fs preserves input order" {
+ run filter_aur_for_fs zfs yay downgrade topgrade
+ [ "$status" -eq 0 ]
+ [ "${lines[0]}" = "yay" ]
+ [ "${lines[2]}" = "topgrade" ]
+}
diff --git a/tests/unit/test_config.bats b/tests/unit/test_config.bats
index af23e4a..4169c5e 100644
--- a/tests/unit/test_config.bats
+++ b/tests/unit/test_config.bats
@@ -5,6 +5,8 @@ setup() {
# shellcheck disable=SC1091
source "${BATS_TEST_DIRNAME}/../../installer/lib/common.sh"
# shellcheck disable=SC1091
+ source "${BATS_TEST_DIRNAME}/../../installer/lib/raid.sh"
+ # shellcheck disable=SC1091
source "${BATS_TEST_DIRNAME}/../../installer/lib/config.sh"
}
@@ -93,6 +95,62 @@ EOF
[[ "$output" == *"4 error"* ]]
}
+@test "validate_config under set -e reports every error, not just the first" {
+ # Reproduces the monolith's call structure: `set -e` is active and
+ # validate_config is invoked as the final command of an && list. A
+ # post-increment that returns the pre-increment value (0 on the first
+ # error) trips set -e and aborts the function after one warning. This
+ # test runs outside bats' `run` shield (which sets +e) so the real
+ # accumulate-and-report behavior is exercised.
+ run bash -c '
+ set -e
+ source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/common.sh"
+ source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/raid.sh"
+ source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/config.sh"
+ HOSTNAME=""; TIMEZONE=""; SELECTED_DISKS=(); ROOT_PASSWORD=""
+ UNATTENDED=true
+ [[ "$UNATTENDED" == true ]] && validate_config
+ '
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"HOSTNAME not set"* ]]
+ [[ "$output" == *"TIMEZONE not set"* ]]
+ [[ "$output" == *"No disks selected"* ]]
+ [[ "$output" == *"ROOT_PASSWORD not set"* ]]
+ [[ "$output" == *"4 error"* ]]
+}
+
+@test "validate_config rejects a RAID_LEVEL invalid for the disk count" {
+ HOSTNAME=h
+ TIMEZONE=UTC
+ ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda /dev/sdb)
+ RAID_LEVEL=raidz1
+ run validate_config
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Invalid RAID_LEVEL"* ]]
+ [[ "$output" == *"raidz1"* ]]
+}
+
+@test "validate_config accepts a RAID_LEVEL valid for the disk count" {
+ HOSTNAME=h
+ TIMEZONE=UTC
+ ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda /dev/sdb /dev/sdc)
+ RAID_LEVEL=raidz1
+ run validate_config
+ [[ "$output" != *"Invalid RAID_LEVEL"* ]]
+}
+
+@test "validate_config accepts an empty RAID_LEVEL for a single disk" {
+ HOSTNAME=h
+ TIMEZONE=UTC
+ ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda)
+ RAID_LEVEL=""
+ run validate_config
+ [[ "$output" != *"Invalid RAID_LEVEL"* ]]
+}
+
@test "validate_config rejects an invalid timezone" {
HOSTNAME="h"
TIMEZONE="Not/A_Real_Zone_xyz"
diff --git a/tests/unit/test_test_install.bats b/tests/unit/test_test_install.bats
index f339baf..bf43dd8 100644
--- a/tests/unit/test_test_install.bats
+++ b/tests/unit/test_test_install.bats
@@ -300,3 +300,53 @@ error: failed to commit transaction (invalid or corrupted package (checksum))
run is_archzfs_cache_corruption ""
[ "$status" -eq 1 ]
}
+
+#############################
+# INSTALLED_PASSWORD scoping
+#############################
+# After the reboot step, run_test switches ssh_cmd over to the installed
+# system's root password. That value must not outlive the test. When it leaks
+# into the next scenario, ssh_cmd presents the installed password to the *live
+# ISO* — whose password is different — so every SSH call fails instantly and
+# the install dies with no output and no package requests.
+#
+# That is exactly what happened on 2026-08-01: the reset was a single `unset`
+# on the success path, three failure paths returned early past it, and one
+# flaky check cascaded into six silent ZFS install failures. The fix declares
+# it `local` in run_test so bash clears it on every return path.
+
+@test "ssh_cmd picks up a caller-scoped INSTALLED_PASSWORD" {
+ # Proves local-instead-of-export still reaches ssh_cmd: bash's dynamic
+ # scoping exposes a caller's local to the functions it calls.
+ sshpass() { echo "$2"; }
+ ssh() { :; }
+ caller_with_local() {
+ local INSTALLED_PASSWORD="installed-secret"
+ ssh_cmd true
+ }
+ run caller_with_local
+ [[ "$output" == *"installed-secret"* ]]
+}
+
+@test "a caller-scoped INSTALLED_PASSWORD does not leak past a failed return" {
+ sshpass() { echo "$2"; }
+ ssh() { :; }
+ SSH_PASSWORD="live-iso-password"
+ failing_caller() {
+ local INSTALLED_PASSWORD="installed-secret"
+ return 1
+ }
+ failing_caller || true
+ run ssh_cmd true
+ [[ "$output" == *"live-iso-password"* ]]
+ [[ "$output" != *"installed-secret"* ]]
+}
+
+@test "run_test declares INSTALLED_PASSWORD local and never exports it" {
+ # Structural guard: run_test itself drives qemu and ssh, so this file
+ # can't exercise it directly. An export here would silently restore the
+ # cascade, so pin the shape that prevents it.
+ local src="${BATS_TEST_DIRNAME}/../../scripts/test-install.sh"
+ grep -qE '^[[:space:]]*local INSTALLED_PASSWORD=' "$src"
+ ! grep -qE '^[[:space:]]*export INSTALLED_PASSWORD' "$src"
+}