diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/unit/test_archangel.bats | 95 | ||||
| -rw-r--r-- | tests/unit/test_btrfs.bats | 35 | ||||
| -rw-r--r-- | tests/unit/test_build_aur.bats | 11 | ||||
| -rw-r--r-- | tests/unit/test_common.bats | 70 | ||||
| -rw-r--r-- | tests/unit/test_config.bats | 58 | ||||
| -rw-r--r-- | tests/unit/test_test_install.bats | 50 |
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" +} |
