diff options
Diffstat (limited to 'tests/installer-steps')
| -rw-r--r-- | tests/installer-steps/test_check_disk_space.py | 94 | ||||
| -rw-r--r-- | tests/installer-steps/test_ensure_nvme_early_module.py | 95 | ||||
| -rw-r--r-- | tests/installer-steps/test_grub_cmdline.py | 121 | ||||
| -rw-r--r-- | tests/installer-steps/test_idempotency_cluster.py | 119 | ||||
| -rw-r--r-- | tests/installer-steps/test_install_executable.py | 66 | ||||
| -rw-r--r-- | tests/installer-steps/test_pacman_hook_order.py | 72 | ||||
| -rw-r--r-- | tests/installer-steps/test_replace_sudoers_pacnew.py | 76 | ||||
| -rw-r--r-- | tests/installer-steps/test_required_software.py | 58 | ||||
| -rw-r--r-- | tests/installer-steps/test_run_step.py | 92 | ||||
| -rw-r--r-- | tests/installer-steps/test_set_user_password.py | 56 | ||||
| -rw-r--r-- | tests/installer-steps/test_validate_yesno.py | 57 |
11 files changed, 896 insertions, 10 deletions
diff --git a/tests/installer-steps/test_check_disk_space.py b/tests/installer-steps/test_check_disk_space.py new file mode 100644 index 0000000..deec6d9 --- /dev/null +++ b/tests/installer-steps/test_check_disk_space.py @@ -0,0 +1,94 @@ +"""Test the disk-space pre-flight gate. + +Two bugs the extracted check_disk_space fixes: + +1. `df /` wraps to two lines when the source device name is long (common on a + live ISO / device-mapper root). The old `df / | awk 'NR==2 {print $4}'` + then reads the device-name line, gets an empty $4, and wrongly aborts on a + disk with plenty of space. `df -P /` forces POSIX single-line output. +2. The old code truncated KB -> GB with integer division before comparing, + biasing the gate against the user near the threshold. The check now compares + available KB against the minimum in KB directly. + +Method: sed-extract check_disk_space from the real `archsetup`, run it with a +fake df (controlled output, and wrapped output for the plain-df regression) and +assert the gate passes/aborts correctly. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_check_disk_space +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +# Fake df: single, correct POSIX line for `df -P`; a WRAPPED two-line body for +# plain `df`. If check_disk_space ever reverts to plain `df`, the wrapped body +# yields an empty Available field and the ample-space test fails -- that is the +# regression guard for bug 1. +FAKE_DF = textwrap.dedent("""\ + df() {{ + if [ "$1" = "-P" ]; then + printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on" + printf '%s\\n' "/dev/mapper/root {avail} 100 {avail} 1% /" + else + printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on" + printf '%s\\n' "/dev/mapper/a-very-long-device-mapper-name-that-wraps" + printf '%s\\n' " {avail} 100 {avail} 1% /" + fi + }} +""") + + +def run_check(available_kb, min_gb=20): + """Run check_disk_space with fake df reporting available_kb free.""" + script = textwrap.dedent(f"""\ + min_disk_space_gb={min_gb} + {FAKE_DF.format(avail=available_kb)} + source <(sed -n '/^check_disk_space() {{/,/^}}/p' "{ARCHSETUP}") + check_disk_space + echo "REACHED-END" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class CheckDiskSpace(unittest.TestCase): + # min 20GB -> 20 * 1024 * 1024 = 20971520 KB + MIN_KB = 20 * 1024 * 1024 + + def test_ample_space_passes(self): + # ~95GB free; also the wrapped-df regression guard (plain df would + # yield an empty Available and wrongly abort). + r = run_check(99000000) + self.assertIn("REACHED-END", r.stdout) + self.assertIn("[OK] Disk space", r.stdout) + + def test_insufficient_space_aborts(self): + r = run_check(5 * 1024 * 1024) # 5GB + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + def test_boundary_exact_minimum_passes(self): + r = run_check(self.MIN_KB) # exactly 20GB in KB + self.assertIn("REACHED-END", r.stdout) + + def test_boundary_one_kb_under_minimum_aborts(self): + r = run_check(self.MIN_KB - 1) + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + def test_empty_df_output_aborts(self): + # df field unparseable -> treat as zero, abort rather than crash. + r = run_check("") + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_ensure_nvme_early_module.py b/tests/installer-steps/test_ensure_nvme_early_module.py new file mode 100644 index 0000000..8e3f036 --- /dev/null +++ b/tests/installer-steps/test_ensure_nvme_early_module.py @@ -0,0 +1,95 @@ +"""Test the nvme early-module step. + +Two bugs the extracted ensure_nvme_early_module fixes: + +1. The MODULES=(... nvme) edit was only compiled into the initramfs by a + mkinitcpio -P that ran behind `if ! is_zfs_root`, so on ZFS-root machines + the early-load hardening never landed. The step now rebuilds whenever it + changed the file, unconditionally. +2. The already-present check grepped the whole file for "nvme", so a comment + or an unrelated token (nvme_tcp) anywhere in mkinitcpio.conf skipped the + edit. The check now looks for the nvme word on the MODULES line only. + +Method: sed-extract ensure_nvme_early_module from the real `archsetup`, point +it at a temp mkinitcpio.conf, and fake has_nvme_drives/backup_system_file/ +mkinitcpio/display/error_warn. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_ensure_nvme_early_module +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(conf_body, has_nvme=True): + with tempfile.TemporaryDirectory() as d: + conf = os.path.join(d, "mkinitcpio.conf") + with open(conf, "w") as f: + f.write(conf_body) + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + backup_system_file() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + has_nvme_drives() {{ return {0 if has_nvme else 1}; }} + # stdout is redirected into $logfile at the call site, so the fake + # records its invocation in a side file instead. + mkinitcpio() {{ echo "MKINITCPIO $*" >> "{d}/mk.log"; }} + source <(sed -n '/^ensure_nvme_early_module() {{/,/^}}/p' "{ARCHSETUP}") + ensure_nvme_early_module "{conf}" + echo "RC=$?" + echo "CONF:[$(cat "{conf}")]" + [ -f "{d}/mk.log" ] && cat "{d}/mk.log" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class EnsureNvmeEarlyModule(unittest.TestCase): + def test_empty_modules_gets_nvme_and_rebuilds(self): + r = run("MODULES=()\nHOOKS=(base udev)\n") + self.assertIn("MODULES=(nvme)", r.stdout) + self.assertIn("MKINITCPIO -P", r.stdout, + "the initramfs must rebuild after the MODULES edit") + + def test_populated_modules_appends_nvme_and_rebuilds(self): + r = run("MODULES=(btrfs)\n") + self.assertIn("MODULES=(btrfs nvme)", r.stdout) + self.assertIn("MKINITCPIO -P", r.stdout) + + def test_nvme_already_present_no_edit_no_rebuild(self): + r = run("MODULES=(nvme)\n") + self.assertIn("MODULES=(nvme)", r.stdout) + self.assertNotIn("MODULES=(nvme nvme)", r.stdout) + self.assertNotIn("MKINITCPIO", r.stdout, + "an unchanged conf must not trigger a rebuild (resume idempotence)") + + def test_nvme_mention_elsewhere_does_not_skip_the_edit(self): + # The old whole-file grep skipped the edit when any line mentioned + # nvme; a comment must not mask a missing MODULES entry. + r = run("# early nvme notes: nvme_load=YES\nMODULES=(btrfs)\n") + self.assertIn("MODULES=(btrfs nvme)", r.stdout) + self.assertIn("MKINITCPIO -P", r.stdout) + + def test_nvme_tcp_module_does_not_count_as_nvme(self): + r = run("MODULES=(nvme_tcp)\n") + self.assertIn("MODULES=(nvme_tcp nvme)", r.stdout) + + def test_no_nvme_drives_is_a_noop(self): + r = run("MODULES=()\n", has_nvme=False) + self.assertIn("RC=0", r.stdout) + self.assertIn("CONF:[MODULES=()", r.stdout) + self.assertNotIn("MKINITCPIO", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_grub_cmdline.py b/tests/installer-steps/test_grub_cmdline.py new file mode 100644 index 0000000..8c0b5b0 --- /dev/null +++ b/tests/installer-steps/test_grub_cmdline.py @@ -0,0 +1,121 @@ +"""Test the GRUB cmdline merge. + +Bug (P2): configure_grub rewrote the whole GRUB_CMDLINE_LINUX_DEFAULT line +with a fixed string. A base install that had set cryptdevice=/resume=/zfs= +(or any other boot-critical token) lost it, and the following grub-mkconfig +baked an unbootable config. + +The fix is a merge: every pre-existing token survives, archsetup's tokens are +added, and where both set the same key archsetup's value wins. A safety +assert refuses to write if any existing token's key would vanish. + +Method: sed-extract merge_grub_cmdline (pure) and update_grub_cmdline +(file-level, run against temp grub files with a fake error_warn). + +Run from repo root: + python3 -m unittest tests.installer-steps.test_grub_cmdline +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +EXTRACT = ( + "source <(sed -n '/^merge_grub_cmdline() {{/,/^}}/p;" + "/^update_grub_cmdline() {{/,/^}}/p' \"{a}\")" +).format(a=ARCHSETUP) + + +def run(body): + script = f"logfile=/dev/null\nerror_warn() {{ echo \"WARN: $1\"; return 1; }}\n{EXTRACT}\n{body}\n" + return subprocess.run(["bash", "-c", script], + capture_output=True, text=True, timeout=10) + + +def merge(existing, desired): + r = run(f'merge_grub_cmdline "{existing}" "{desired}"') + return r.stdout.strip() + + +class MergeGrubCmdline(unittest.TestCase): + DESIRED = "rw loglevel=2 quiet splash" + + def test_empty_existing_yields_desired(self): + self.assertEqual(merge("", self.DESIRED), self.DESIRED) + + def test_boot_critical_tokens_survive(self): + out = merge("cryptdevice=UUID=abc:root resume=/dev/nvme0n1p3 root=/dev/mapper/root", + self.DESIRED) + for tok in ("cryptdevice=UUID=abc:root", "resume=/dev/nvme0n1p3", + "root=/dev/mapper/root", "loglevel=2", "quiet", "splash"): + self.assertIn(tok, out.split(), f"{tok} missing from: {out}") + + def test_same_key_desired_value_wins_once(self): + out = merge("loglevel=7 quiet", self.DESIRED).split() + self.assertIn("loglevel=2", out) + self.assertNotIn("loglevel=7", out) + self.assertEqual(out.count("loglevel=2"), 1) + self.assertEqual(out.count("quiet"), 1) + + def test_zfs_token_survives(self): + out = merge("zfs=zroot/ROOT/default", self.DESIRED).split() + self.assertIn("zfs=zroot/ROOT/default", out) + + +class UpdateGrubCmdline(unittest.TestCase): + def run_update(self, grub_body): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "grub") + with open(path, "w") as f: + f.write(grub_body) + r = run(f'update_grub_cmdline "{path}"; echo "RC=$?"') + with open(path) as f: + return r, f.read() + + def line(self, content): + return [ln for ln in content.splitlines() + if ln.startswith('GRUB_CMDLINE_LINUX_DEFAULT=')] + + def test_existing_tokens_preserved_in_file(self): + _, content = self.run_update(textwrap.dedent("""\ + GRUB_TIMEOUT=5 + GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 cryptdevice=UUID=abc:root resume=/dev/sda2" + GRUB_DISABLE_RECOVERY=true + """)) + lines = self.line(content) + self.assertEqual(len(lines), 1) + val = lines[0] + self.assertIn("cryptdevice=UUID=abc:root", val) + self.assertIn("resume=/dev/sda2", val) + self.assertIn("loglevel=2", val) # archsetup's value wins + self.assertNotIn("loglevel=3", val) + self.assertIn("quiet", val) + # other lines untouched + self.assertIn("GRUB_TIMEOUT=5", content) + self.assertIn("GRUB_DISABLE_RECOVERY=true", content) + + def test_quoting_stays_a_single_pair(self): + _, content = self.run_update('GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n') + val = self.line(content)[0] + self.assertEqual(val.count('"'), 2) + self.assertRegex(val, r'^GRUB_CMDLINE_LINUX_DEFAULT="[^"]*"$') + + def test_commented_line_gets_active_line_appended(self): + _, content = self.run_update('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n') + self.assertEqual(len(self.line(content)), 1) + self.assertIn('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"', content) + + def test_idempotent_on_rerun(self): + body = 'GRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=abc:root"\n' + _, once = self.run_update(body) + _, twice = self.run_update(once) + self.assertEqual(once, twice) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_idempotency_cluster.py b/tests/installer-steps/test_idempotency_cluster.py new file mode 100644 index 0000000..ecb279d --- /dev/null +++ b/tests/installer-steps/test_idempotency_cluster.py @@ -0,0 +1,119 @@ +"""Test the resume-idempotency cluster: three extracted installer helpers. + +Each fix targets a step that misbehaves on a resume re-run or with unexpected +system state: + +- crontab_append_once: the log-cleanup cron line was appended unconditionally, + so a resume re-run of essential_services stacked a duplicate line each time. + The helper appends only when no existing line matches. +- zfs_scrub_timer_units: the scrub timer used `zpool list | head -1`, which + picks an arbitrary pool when several exist and yields the malformed unit + `zfs-scrub-weekly@.timer` when none do. The helper emits one unit per pool + and nothing for no pools. +- enable_user_service: gamemode was enabled via `systemctl --user enable`, + which the script itself documents fails at install time (no user bus). The + helper creates the wants symlink directly, the pattern syncthing already uses. + +Method: sed-extract each helper from the real `archsetup` and drive it with +plain stdin/args (the first two are pure) or a temp HOME (the third). + +Run from repo root: + python3 -m unittest tests.installer-steps.test_idempotency_cluster +""" + +import os +import subprocess +import tempfile +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") +ME = os.environ.get("USER") or __import__("getpass").getuser() + + +def extract(func): + return ( + "source <(sed -n '/^{f}() {{/,/^}}/p' \"{a}\")".format(f=func, a=ARCHSETUP) + ) + + +def run(func, body, stdin=None): + script = f"{extract(func)}\n{body}\n" + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + input=stdin, + ) + + +class CrontabAppendOnce(unittest.TestCase): + def call(self, existing, match, line): + body = f'crontab_append_once {match!r} {line!r}' + return run("crontab_append_once", body, stdin=existing).stdout + + def test_appends_when_absent(self): + out = self.call("", "log-cleanup", "0 12 * * * run-cleanup") + self.assertIn("0 12 * * * run-cleanup", out) + + def test_preserves_existing_and_appends(self): + out = self.call("0 0 * * * other-job\n", "log-cleanup", "0 12 * * * cleanup") + self.assertIn("other-job", out) + self.assertIn("0 12 * * * cleanup", out) + + def test_does_not_duplicate_when_present(self): + existing = "0 12 * * * $HOME/.local/bin/cron/log-cleanup\n" + out = self.call(existing, "cron/log-cleanup", "0 12 * * * dup") + self.assertNotIn("0 12 * * * dup", out) + self.assertEqual(out.count("log-cleanup"), 1) + + +class ZfsScrubTimerUnits(unittest.TestCase): + def units(self, pools_text): + out = run("zfs_scrub_timer_units", "zfs_scrub_timer_units", stdin=pools_text).stdout + return [ln for ln in out.splitlines() if ln] + + def test_single_pool(self): + self.assertEqual(self.units("zroot\n"), + ["zfs-scrub-weekly@zroot.timer"]) + + def test_no_pool_yields_no_unit(self): + self.assertEqual(self.units(""), []) + + def test_multiple_pools_each_get_a_unit(self): + self.assertEqual( + self.units("zroot\ntank\n"), + ["zfs-scrub-weekly@zroot.timer", "zfs-scrub-weekly@tank.timer"], + ) + + def test_blank_lines_are_skipped(self): + self.assertEqual(self.units("zroot\n\n"), + ["zfs-scrub-weekly@zroot.timer"]) + + +class EnableUserService(unittest.TestCase): + def test_creates_wants_symlink_in_user_config(self): + with tempfile.TemporaryDirectory() as home: + body = ( + f'enable_user_service {ME!r} gamemoded.service ' + f'/usr/lib/systemd/user/gamemoded.service {home!r}\n' + f'link="{home}/.config/systemd/user/default.target.wants/gamemoded.service"\n' + f'[ -L "$link" ] && echo "LINK=yes" || echo "LINK=no"\n' + f'echo "TARGET=$(readlink "$link")"' + ) + out = run("enable_user_service", body).stdout + self.assertIn("LINK=yes", out) + self.assertIn("TARGET=/usr/lib/systemd/user/gamemoded.service", out) + + def test_idempotent_on_rerun(self): + with tempfile.TemporaryDirectory() as home: + svc = "/usr/lib/systemd/user/gamemoded.service" + one = ( + f'enable_user_service {ME!r} gamemoded.service {svc!r} {home!r}\n' + f'enable_user_service {ME!r} gamemoded.service {svc!r} {home!r}\n' + f'echo "RC=$?"' + ) + out = run("enable_user_service", one).stdout + self.assertIn("RC=0", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_install_executable.py b/tests/installer-steps/test_install_executable.py new file mode 100644 index 0000000..6a2ecfd --- /dev/null +++ b/tests/installer-steps/test_install_executable.py @@ -0,0 +1,66 @@ +"""Test the guarded executable install helper. + +With `set -e` off, the installer's bare `cp script /usr/local/bin/...` followed +by `chmod +x` failed silently when the source was missing or partial, leaving a +systemd service with a dead ExecStart or a pacman hook pointing at a script that +was never installed. install_executable guards the copy and the chmod, warns on +failure, and skips the chmod when the copy did not land. + +Method: sed-extract install_executable from the real `archsetup` and run it +against real temp files (no fakes needed) with a fake error_warn recorder. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_executable +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(src, dest): + script = textwrap.dedent(f"""\ + logfile=/dev/null + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^install_executable() {{/,/^}}/p' "{ARCHSETUP}") + install_executable "{src}" "{dest}" + echo "RC=$?" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class InstallExecutable(unittest.TestCase): + def test_copies_and_makes_executable(self): + with tempfile.TemporaryDirectory() as d: + src = os.path.join(d, "myscript") + dest = os.path.join(d, "bin", "myscript") + os.mkdir(os.path.join(d, "bin")) + with open(src, "w") as f: + f.write("#!/bin/sh\necho hi\n") + r = run(src, dest) + self.assertIn("RC=0", r.stdout) + self.assertTrue(os.path.exists(dest)) + self.assertTrue(os.access(dest, os.X_OK), "dest must be executable") + self.assertNotIn("WARN", r.stdout) + + def test_missing_source_warns_and_skips_chmod(self): + with tempfile.TemporaryDirectory() as d: + src = os.path.join(d, "does-not-exist") + dest = os.path.join(d, "bin", "myscript") + os.mkdir(os.path.join(d, "bin")) + r = run(src, dest) + self.assertIn("WARN: copying", r.stdout, + "a failed copy must warn, not silently continue") + self.assertNotIn("RC=0", r.stdout, "helper returns non-zero on copy failure") + self.assertFalse(os.path.exists(dest)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_pacman_hook_order.py b/tests/installer-steps/test_pacman_hook_order.py index 49acdbe..45058dd 100644 --- a/tests/installer-steps/test_pacman_hook_order.py +++ b/tests/installer-steps/test_pacman_hook_order.py @@ -1,26 +1,78 @@ -"""Regression tests for the pacman hook ordering safety invariant.""" +"""Regression tests for the pacman hook ordering safety invariant. + +pacman runs hooks in filename sort order, so the safety hooks archsetup +installs (the pre-pacman ZFS snapshot and the live-update guard, both +PreTransaction) must carry names that sort before mkinitcpio's stock +60-mkinitcpio-remove.hook. Otherwise a blocked transaction can remove the +current initramfs before the guard aborts, or after the snapshot window. + +The earlier version of this test asserted the ordering by comparing two string +literals to each other, which is constant-true and never looked at the source. +These tests extract the hook filenames the installer actually writes and +compare those, so a rename in the source flows into the assertion. +""" import os +import re import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") +MKINITCPIO_REMOVE = "60-mkinitcpio-remove.hook" # stock mkinitcpio hook name + +WRITE_RE = re.compile(r">\s*/etc/pacman\.d/hooks/(\S+\.hook)\b") + + +def written_hooks(text): + """Hook filenames the source writes (redirections into the hooks dir).""" + names = [] + for line in text.splitlines(): + if "rm -f" in line: + continue + m = WRITE_RE.search(line) + if m: + names.append(m.group(1)) + return names + class PacmanHookOrderTests(unittest.TestCase): - def test_safety_hooks_precede_mkinitcpio_removal(self): + @classmethod + def setUpClass(cls): with open(ARCHSETUP, encoding="utf-8") as source: - text = source.read() + cls.text = source.read() + cls.hooks = written_hooks(cls.text) + def hook_named(self, suffix): + matches = [h for h in self.hooks if h.endswith(suffix)] + self.assertEqual( + len(matches), 1, + f"expected exactly one written hook ending {suffix}, got {matches}") + return matches[0] + + def test_safety_hooks_sort_before_mkinitcpio_removal(self): + # The invariant pacman actually enforces: filename sort order. + for suffix in ("zfs-snapshot.hook", "hypr-live-update-guard.hook"): + name = self.hook_named(suffix) + self.assertLess( + name, MKINITCPIO_REMOVE, + f"{name} must sort before {MKINITCPIO_REMOVE} or the guard " + "runs after the initramfs removal") + + def test_every_written_hook_has_an_ordering_prefix(self): + # Ordering is deliberate; a hook without a numeric prefix sorts by + # accident of its first letter. + self.assertTrue(self.hooks, "extraction found no written hooks") + for name in self.hooks: + self.assertRegex(name, r"^\d{2}-", + f"{name} lacks the two-digit ordering prefix") + + def test_stale_unprefixed_hooks_are_cleaned_up(self): + # Migration from installs made before the numeric prefixes. + self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", self.text) self.assertIn( - "cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook", text) - self.assertIn( - "cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook", text) - self.assertLess("05-zfs-snapshot.hook", "60-mkinitcpio-remove.hook") - self.assertLess("10-hypr-live-update-guard.hook", "60-mkinitcpio-remove.hook") - self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", text) - self.assertIn("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", text) + "rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", self.text) if __name__ == "__main__": diff --git a/tests/installer-steps/test_replace_sudoers_pacnew.py b/tests/installer-steps/test_replace_sudoers_pacnew.py new file mode 100644 index 0000000..8bfa914 --- /dev/null +++ b/tests/installer-steps/test_replace_sudoers_pacnew.py @@ -0,0 +1,76 @@ +"""Test the guarded sudoers.pacnew replacement. + +Bug (Minor, hard consequence): the installer did + [ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers +with no validation. A malformed pacnew replaces /etc/sudoers with a file sudo +refuses to parse, locking out privilege escalation right before the NOPASSWD +rule is appended. replace_sudoers_pacnew now runs `visudo -cf` on the pacnew +first and only copies a file that validates; a failure warns (non-fatal) and +leaves the working sudoers in place. + +Method: sed-extract replace_sudoers_pacnew from the real `archsetup`, pass a +temp pacnew + temp target (the function's positional-arg testability seam), and +drive it with a fake visudo (controlled validation result) and a fake +error_warn recorder. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_replace_sudoers_pacnew +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(*, pacnew_exists, visudo_rc): + """Run replace_sudoers_pacnew with a temp pacnew/target and fake visudo.""" + with tempfile.TemporaryDirectory() as d: + pacnew = os.path.join(d, "sudoers.pacnew") + target = os.path.join(d, "sudoers") + with open(target, "w") as f: + f.write("ORIGINAL\n") + if pacnew_exists: + with open(pacnew, "w") as f: + f.write("NEW\n") + script = textwrap.dedent(f"""\ + logfile=/dev/null + display() {{ :; }} + visudo() {{ return {visudo_rc}; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^replace_sudoers_pacnew() {{/,/^}}/p' "{ARCHSETUP}") + replace_sudoers_pacnew "{pacnew}" "{target}" + echo "RC=$?" + echo "TARGET=[$(cat "{target}")]" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class ReplaceSudoersPacnew(unittest.TestCase): + def test_valid_pacnew_is_copied(self): + r = run(pacnew_exists=True, visudo_rc=0) + self.assertIn("TARGET=[NEW]", r.stdout) + self.assertNotIn("WARN", r.stdout) + + def test_invalid_pacnew_warns_and_keeps_original(self): + r = run(pacnew_exists=True, visudo_rc=1) + self.assertIn("WARN", r.stdout, + "a pacnew that fails visudo must warn, not clobber sudoers") + self.assertIn("TARGET=[ORIGINAL]", r.stdout, + "the working sudoers must survive a malformed pacnew") + + def test_no_pacnew_is_a_noop(self): + r = run(pacnew_exists=False, visudo_rc=0) + self.assertIn("RC=0", r.stdout) + self.assertIn("TARGET=[ORIGINAL]", r.stdout) + self.assertNotIn("WARN", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_required_software.py b/tests/installer-steps/test_required_software.py new file mode 100644 index 0000000..85b01d8 --- /dev/null +++ b/tests/installer-steps/test_required_software.py @@ -0,0 +1,58 @@ +"""Test that install_required_software declares the expected base packages. + +inetutils provides /usr/bin/ftp, which TRAMP's /ftp: method (ange-ftp) shells +out to; Arch ships no command-line ftp client by default. It must be in the +base install so every machine gets it (Craig's dirvish config has an FTP +quick-access entry that depends on it). + +Method mirrors test_orchestrators: sed-extract install_required_software from +the real `archsetup`, stub pacman_install as a recorder that echoes the package +name, run, and assert membership. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_required_software +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def required_packages(): + """Return (exit_code, [package, ...]) declared by install_required_software.""" + script = textwrap.dedent(f"""\ + display() {{ :; }} + pacman_install() {{ echo "$1"; }} + source <(sed -n '/^install_required_software() {{/,/^}}/p' "{ARCHSETUP}") + install_required_software + """) + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + return result.returncode, result.stdout.split() + + +class RequiredSoftware(unittest.TestCase): + def test_installs_inetutils_for_ftp(self): + rc, pkgs = required_packages() + self.assertEqual(rc, 0) + self.assertIn( + "inetutils", pkgs, + "inetutils (provides /usr/bin/ftp for TRAMP's /ftp: method) must be " + "in the base install", + ) + + def test_core_base_packages_present(self): + # Guard a few load-bearing entries so a bad edit to the list is caught. + rc, pkgs = required_packages() + self.assertEqual(rc, 0) + for p in ("base-devel", "git", "stow", "openssh", "curl"): + self.assertIn(p, pkgs, f"{p} dropped from the base install") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_run_step.py b/tests/installer-steps/test_run_step.py new file mode 100644 index 0000000..994e8c5 --- /dev/null +++ b/tests/installer-steps/test_run_step.py @@ -0,0 +1,92 @@ +"""Test run_step's state-marker and scope behavior. + +Bug (Major): run_step marked a step complete only when its function returned 0. +But error_fatal exits the script outright, so a step function that *returns* has +already survived every fatal check -- a non-zero return can only come from a +trailing non-fatal warning (error_warn returns 1). The old code read that as a +step failure and withheld the state marker, so the step re-ran on every resume. +run_step now records the marker whenever the function returns. + +Bug (Minor): run_step's step_name/step_func leaked to global scope. + +Method: sed-extract run_step + step_completed + mark_complete from the real +`archsetup`, point state_dir at a temp dir, and drive it with fake step +functions. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_run_step +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +EXTRACT = ( + "source <(sed -n '/^run_step() {{/,/^}}/p;" + "/^step_completed() {{/,/^}}/p;" + "/^mark_complete() {{/,/^}}/p' \"{archsetup}\")" +).format(archsetup=ARCHSETUP) + + +def run(step_body, pre_marked=False): + """Run run_step against a fake step function whose body is step_body.""" + with tempfile.TemporaryDirectory() as state_dir: + marker_setup = ( + f'mkdir -p "{state_dir}"; echo pre > "{state_dir}/mystep"\n' + if pre_marked else "" + ) + script = textwrap.dedent(f"""\ + state_dir="{state_dir}" + {marker_setup} + mystep() {{ + {step_body} + }} + {EXTRACT} + run_step "mystep" "mystep" + rc=$? + echo "RUN_STEP_RC=$rc" + # marker present? + [ -f "$state_dir/mystep" ] && echo "MARKED=yes" || echo "MARKED=no" + # locals must not leak + echo "LEAK_step_name=[${{step_name:-}}]" + echo "LEAK_step_func=[${{step_func:-}}]" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class RunStep(unittest.TestCase): + def test_success_marks_complete(self): + r = run("return 0") + self.assertIn("RUN_STEP_RC=0", r.stdout) + self.assertIn("MARKED=yes", r.stdout) + + def test_nonfatal_warning_return_still_marks_complete(self): + # The bug fix: a step ending in a non-fatal warning returns 1 but has + # done its work; it must be marked so resume does not re-run it. + r = run("return 1") + self.assertIn("MARKED=yes", r.stdout, + "a returned-non-zero step must still record its marker") + self.assertIn("RUN_STEP_RC=0", r.stdout, + "run_step returns 0 so the main loop treats it as handled") + + def test_already_completed_skips_and_does_not_rerun(self): + # Body would create a sentinel if it ran; pre-marked step must skip it. + r = run("echo RAN-BODY", pre_marked=True) + self.assertIn("Skipping", r.stdout) + self.assertNotIn("RAN-BODY", r.stdout) + + def test_locals_do_not_leak_to_global_scope(self): + r = run("return 0") + self.assertIn("LEAK_step_name=[]", r.stdout) + self.assertIn("LEAK_step_func=[]", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_set_user_password.py b/tests/installer-steps/test_set_user_password.py new file mode 100644 index 0000000..9f21b3a --- /dev/null +++ b/tests/installer-steps/test_set_user_password.py @@ -0,0 +1,56 @@ +"""Test that the primary user's password is set under a guard. + +archsetup runs with `set -e` OFF (line 21), so an unguarded `chpasswd` that +fails silently leaves the primary user with no password and no log entry -- an +unloggable account on a fresh install. set_user_password guards the chpasswd +with error_fatal so a failure aborts loudly instead of passing unnoticed. + +Method: sed-extract set_user_password from the real `archsetup`, run it with a +fake chpasswd (controlled exit) and a fake error_fatal recorder. Assert the +guard fires on failure and stays quiet on success. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_set_user_password +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run_set_password(chpasswd_rc): + """Run set_user_password with a fake chpasswd exiting chpasswd_rc.""" + script = textwrap.dedent(f"""\ + password="hunter2" + logfile=/dev/null + display() {{ :; }} + chpasswd() {{ cat >/dev/null; return {chpasswd_rc}; }} + error_fatal() {{ echo "FATAL: $1"; exit 9; }} + source <(sed -n '/^set_user_password() {{/,/^}}/p' "{ARCHSETUP}") + set_user_password "alice" + echo "REACHED-END" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class SetUserPassword(unittest.TestCase): + def test_success_does_not_abort(self): + r = run_set_password(0) + self.assertIn("REACHED-END", r.stdout) + self.assertNotIn("FATAL", r.stdout) + + def test_failure_aborts_with_error_fatal(self): + r = run_set_password(1) + self.assertIn("FATAL: setting password for alice", r.stdout, + "a chpasswd failure must abort via error_fatal") + self.assertNotIn("REACHED-END", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_validate_yesno.py b/tests/installer-steps/test_validate_yesno.py new file mode 100644 index 0000000..da78fc5 --- /dev/null +++ b/tests/installer-steps/test_validate_yesno.py @@ -0,0 +1,57 @@ +"""Test the validate_yesno config-validation helper. + +The installer had four near-identical blocks validating that AUTOLOGIN, +NO_GPU_DRIVERS, INSTALL_CLAUDE_CODE, and INSTALL_DEVICE_UDEV_RULES are empty or +exactly yes/no. validate_yesno collapses them into one testable helper: empty +passes (the default), yes/no pass, anything else fails with a named error. + +Method: sed-extract validate_yesno from the real `archsetup` and drive it with +plain args. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_validate_yesno +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(name, value): + script = textwrap.dedent(f"""\ + source <(sed -n '/^validate_yesno() {{/,/^}}/p' "{ARCHSETUP}") + validate_yesno {name!r} {value!r} + echo "RC=$?" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class ValidateYesno(unittest.TestCase): + def test_yes_passes(self): + self.assertIn("RC=0", run("AUTOLOGIN", "yes").stdout) + + def test_no_passes(self): + self.assertIn("RC=0", run("AUTOLOGIN", "no").stdout) + + def test_empty_passes(self): + self.assertIn("RC=0", run("AUTOLOGIN", "").stdout) + + def test_other_value_fails_with_named_error(self): + r = run("NO_GPU_DRIVERS", "maybe") + self.assertNotIn("RC=0", r.stdout) + self.assertIn("NO_GPU_DRIVERS", r.stderr) + self.assertIn("maybe", r.stderr) + + def test_capitalized_yes_fails(self): + # The values are compared exactly; "Yes" is not accepted. + self.assertNotIn("RC=0", run("AUTOLOGIN", "Yes").stdout) + + +if __name__ == "__main__": + unittest.main() |
