diff options
Diffstat (limited to 'tests/installer-steps')
16 files changed, 2277 insertions, 6 deletions
diff --git a/tests/installer-steps/test_clone_user_repos.py b/tests/installer-steps/test_clone_user_repos.py new file mode 100644 index 0000000..51d8434 --- /dev/null +++ b/tests/installer-steps/test_clone_user_repos.py @@ -0,0 +1,155 @@ +"""Test clone_user_repos: the two user repos are cloned with full history. + +archsetup and dotfiles are not build directories. They are the two repos I +actively develop in on every machine this installer builds, so a shallow clone +is wrong for both. Velox came back from its 2026-08-13 rebuild with 7 commits +of history in each instead of 851, and nothing about the tree said so. + +The quiet failure is what makes this worth a test rather than a one-line fix. +`git log -- <path>` against a shallow clone does not error; it answers "no +commits". So a credential-history check run on that machine reported five +sensitive files absent from history and exited clean, when the real answer was +that the clone could not see the history they live in. A security question came +back falsely reassuring. Everything else it breaks — blame, bisect, any +archaeology past the graft point — is merely annoying by comparison. + +The AUR build clones are a different case and stay shallow: they are throwaway +build trees, cloned to run `make install` and then discarded, where history has +no value and the download cost is real. So this suite asserts both halves — +full history for the two user repos, and depth still pinned on the AUR path — +because a fix applied with too broad a brush would regress the build clones +without failing any test that only looked at the user repos. + +Method: sed-extract clone_user_repos from the real `archsetup`, fake git / +mkdir / chown / display / error_warn / error_fatal, and read back the git +command lines the function issued. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_clone_user_repos +""" + +import os +import re +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(clone_fails=False, make_git_dir=True): + """Drive clone_user_repos with every side effect faked. + + dotfiles_dir is pre-created with a .git so the function's "is this a real + checkout?" guard passes on the happy path; make_git_dir=False exercises the + guard itself. + """ + with tempfile.TemporaryDirectory() as d: + dotfiles_dir = os.path.join(d, "dotfiles") + os.makedirs(dotfiles_dir) + if make_git_dir: + os.makedirs(os.path.join(dotfiles_dir, ".git")) + clone_rc = 1 if clone_fails else 0 + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + username=testuser + archsetup_repo="https://example.invalid/archsetup.git" + dotfiles_repo="https://example.invalid/dotfiles.git" + dotfiles_branch=main + dotfiles_dir="{dotfiles_dir}" + display() {{ :; }} + mkdir() {{ echo "MKDIR: $*" >> "{d}/calls.log"; return 0; }} + chown() {{ echo "CHOWN: $*" >> "{d}/calls.log"; return 0; }} + git() {{ + echo "GIT: $*" >> "{d}/calls.log" + case "$1" in + clone) return {clone_rc} ;; + *) return 0 ;; + esac + }} + error_warn() {{ echo "WARN: $1" >> "{d}/calls.log"; return 1; }} + error_fatal() {{ echo "FATAL: $1" >> "{d}/calls.log"; exit 1; }} + source <(sed -n '/^clone_user_repos() {{/,/^}}/p' "{ARCHSETUP}") + clone_user_repos + echo "RC=$?" >> "{d}/calls.log" + exit 0 + """) + subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + with open(os.path.join(d, "calls.log")) as fh: + return fh.read() + + +def clone_lines(log): + return [ln for ln in log.splitlines() if ln.startswith("GIT: clone")] + + +class CloneUserRepos(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_both_user_repos_are_cloned(self): + lines = clone_lines(run()) + self.assertEqual(len(lines), 2, + f"expected an archsetup clone and a dotfiles clone, got: {lines}") + self.assertTrue(any("archsetup.git" in ln for ln in lines)) + self.assertTrue(any("dotfiles.git" in ln for ln in lines)) + + def test_archsetup_clone_carries_full_history(self): + """A shallow archsetup clone answers history questions wrongly.""" + line = next(ln for ln in clone_lines(run()) if "archsetup.git" in ln) + self.assertNotIn("--depth", line, + "archsetup is a working repo, not a build tree — a shallow " + "clone makes `git log -- <path>` answer 'no commits' instead " + "of failing, which is how a credential-history check came " + "back falsely clean on velox") + + def test_dotfiles_clone_carries_full_history(self): + line = next(ln for ln in clone_lines(run()) if "dotfiles.git" in ln) + self.assertNotIn("--depth", line, + "dotfiles is a working repo, not a build tree") + + def test_dotfiles_clone_still_pins_the_branch(self): + """Dropping --depth must not disturb the --branch argument beside it.""" + line = next(ln for ln in clone_lines(run()) if "dotfiles.git" in ln) + self.assertIn("--branch main", line) + + # ---------------------------------------------------------- boundary ---- + def test_no_user_repo_clone_is_shallow_by_any_spelling(self): + """--depth, --depth=N and -depth are all shallow; catch the lot.""" + for line in clone_lines(run()): + self.assertNotRegex(line, r"(^|\s)-{1,2}depth(\s|=)", + f"user-repo clone must be full: {line}") + + def test_aur_build_clones_stay_shallow(self): + """The fix must not over-apply — build trees are throwaway. + + Read against the real file rather than the extracted function, because + these clones live in a different function entirely and the risk being + guarded is a careless repo-wide sed. + """ + with open(ARCHSETUP) as fh: + source = fh.read() + build_clones = re.findall(r"^.*git clone.*build_dir.*$", source, re.M) + self.assertTrue(build_clones, "expected AUR build clones to exist") + for line in build_clones: + self.assertIn("--depth 1", line, + f"AUR build clone should stay shallow: {line.strip()}") + + # ------------------------------------------------------------- error ---- + def test_clone_failure_is_reported_not_swallowed(self): + log = run(clone_fails=True) + self.assertIn("WARN:", log, + "a failed clone must surface through error_warn") + + def test_dotfiles_clone_producing_no_checkout_is_fatal(self): + """The stow/restore steps downstream need a real checkout.""" + log = run(make_git_dir=False) + self.assertIn("FATAL:", log) + self.assertNotIn("RC=", log, "error_fatal must halt, not fall through") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_configure_backlight_access.py b/tests/installer-steps/test_configure_backlight_access.py new file mode 100644 index 0000000..6fd171a --- /dev/null +++ b/tests/installer-steps/test_configure_backlight_access.py @@ -0,0 +1,139 @@ +"""Test configure_backlight_access — udev rule for brightness writes. + +Arch's brightnessctl ships no udev rules: it relies on logind, which grants +brightness writes only to the *active seat session*. Anything outside that +session — a script, a remote shell, a panel launched into a different +session — gets EPERM against root-owned sysfs. Found on velox 2026-08-13: +on a fresh install the desktop settings panel's screen and keyboard +brightness sliders were both inert. + +The step drops a rule making the brightness attributes group-writable by +video (the group create_user already adds the user to). leds are granted to +video as well rather than input, so nobody needs input-group membership — +and the keylogging surface that carries — just to dim a keyboard. + +Method: sed-extract configure_backlight_access from the real `archsetup`, +point it at a temp rules dir, and assert on the file it writes. + + python3 -m unittest tests.installer-steps.test_configure_backlight_access +""" + +import os +import re +import stat +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(ruledir, marker=None): + # The step sends udevadm's own output to $logfile, so the stub records + # its argv to a marker file instead of stdout — writing straight to the + # file is unaffected by the caller's redirect. + marker = marker or os.devnull + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + udevadm() {{ echo "UDEVADM: $*" >> "{marker}"; }} + source <(sed -n '/^configure_backlight_access() {{/,/^}}/p' "{ARCHSETUP}") + configure_backlight_access "{ruledir}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class ConfigureBacklightAccess(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_a_rule_covering_backlight_and_keyboard_leds(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + rule = os.path.join(d, "90-backlight.rules") + self.assertTrue(os.path.exists(rule)) + body = open(rule).read() + self.assertIn('SUBSYSTEM=="backlight"', body) + self.assertIn('SUBSYSTEM=="leds"', body) + self.assertIn('KERNEL=="*kbd_backlight"', body) + + def test_grants_the_video_group_write_access(self): + with tempfile.TemporaryDirectory() as d: + run(d) + body = open(os.path.join(d, "90-backlight.rules")).read() + self.assertIn("chgrp video", body) + self.assertIn("chmod g+w", body) + + def test_does_not_use_the_input_group(self): + # Granting leds to input would require input-group membership, + # which also confers read access to every input device. + with tempfile.TemporaryDirectory() as d: + run(d) + body = open(os.path.join(d, "90-backlight.rules")).read() + self.assertNotIn("chgrp input", body) + + def test_rule_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE(os.stat(os.path.join(d, "90-backlight.rules")).st_mode) + self.assertEqual(mode, 0o644) + + def test_reloads_udev_so_the_rule_applies_without_a_reboot(self): + with tempfile.TemporaryDirectory() as d: + marker = os.path.join(d, "udevadm.calls") + run(d, marker=marker) + calls = open(marker).read() + self.assertIn("UDEVADM: control --reload", calls) + self.assertRegex(calls, r"UDEVADM: trigger .*backlight") + self.assertRegex(calls, r"UDEVADM: trigger .*leds") + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_correct_rule(self): + # Compare whole bodies rather than counting lines: a count encodes + # today's line total, so it breaks on a correct edit and passes an + # append regression that happens to hit the same number. + with tempfile.TemporaryDirectory() as d: + run(d) + first = open(os.path.join(d, "90-backlight.rules")).read() + r = run(d) + self.assertEqual(rc_of(r), 0) + second = open(os.path.join(d, "90-backlight.rules")).read() + self.assertEqual(first, second) + + def test_absent_rules_directory_is_created(self): + with tempfile.TemporaryDirectory() as d: + nested = os.path.join(d, "etc", "udev", "rules.d") + r = run(nested) + self.assertEqual(rc_of(r), 0) + self.assertTrue(os.path.exists(os.path.join(nested, "90-backlight.rules"))) + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_directory_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + ruledir = os.path.join(d, "ro") + os.mkdir(ruledir) + os.chmod(ruledir, 0o500) + try: + r = run(ruledir) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + finally: + os.chmod(ruledir, 0o700) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_configure_framework_amd_internal_mic.py b/tests/installer-steps/test_configure_framework_amd_internal_mic.py new file mode 100644 index 0000000..7a45fb1 --- /dev/null +++ b/tests/installer-steps/test_configure_framework_amd_internal_mic.py @@ -0,0 +1,277 @@ +"""Test configure_framework_amd_internal_mic and its DMI gate. + +The Framework Laptop 13 on AMD Ryzen AI 300 (Strix Point and Krackan Point +alike) has a known, still-open kernel bug in the ACP PDM digital-mic path: the +DMIC card (acp-pdm-mach, hw:acppdmmach) returns a constant full-scale rail +(rms 0.99999) instead of audio. With UCM on, alsa-ucm-conf links that card +into the ALC285 codec card, so the only "internal mic" PipeWire offers IS the +broken DMIC, and the built-in microphone is dead in every app. Found on velox +2026-10-06 after the 08-13 reinstall; Framework community threads 81866 and +75748 document it across several HX 370 owners. + +The workaround is a WirePlumber drop-in that turns UCM off (the ALC285 falls +back to its analog-stereo profile, whose Internal Microphone pin carries real +audio) and disables the standalone PDM card so nothing can pick it. The step +installs the shipped copy of that drop-in, and only on this hardware: the +gate reads DMI, not the hostname, because the fix belongs to the board, not +to velox. + +The gate has to be exact in both directions. The same bug does not exist on +the Intel Framework 13 (SOF stack, not ACP), and the Framework Desktop (ratio, +"AMD Ryzen AI Max 300") has no internal mic at all, so a vendor-only or +"AMD"-only match would drop a UCM-off file onto machines whose audio is fine +and silently flatten their codec profiles. + +Method: sed-extract both functions from the real `archsetup`, point the step +at a temp conf.d dir and a fixture DMI dir, and fake display / error_warn. +The shipped drop-in is read from the real configs/ tree so the test pins the +content the installer actually delivers. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_configure_framework_amd_internal_mic +""" + +import os +import re +import stat +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") +DROPIN_SRC = os.path.join(REPO_ROOT, "configs", "wireplumber", "50-disable-ucm.conf") +DROPIN_NAME = "50-disable-ucm.conf" +# The broken DMIC card as velox names it, and the codec card that must stay. +VELOX_PDM_CARD = "alsa_card.pci-0000_c1_00.5-platform-acp-pdm-mach" +VELOX_CODEC_CARD = "alsa_card.pci-0000_c1_00.6" + +# DMI fixtures. The product strings are what the real machines report. +VELOX = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen AI 300 Series)") +RATIO = dict(sys_vendor="Framework", product_name="Desktop (AMD Ryzen AI Max 300 Series)") +INTEL_13 = dict(sys_vendor="Framework", product_name="Laptop (13th Gen Intel Core)") +AMD_7040_13 = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen 7040Series)") +AMD_16 = dict(sys_vendor="Framework", product_name="Laptop 16 (AMD Ryzen 7040 Series)") +OTHER_VENDOR = dict(sys_vendor="LENOVO", product_name="Laptop 13 (AMD Ryzen AI 300 Series)") + + +def write_dmi(dmi_dir, fields): + os.makedirs(dmi_dir, exist_ok=True) + for name, value in fields.items(): + with open(os.path.join(dmi_dir, name), "w") as f: + f.write(value + "\n") + + +def gate(dmi_dir): + script = textwrap.dedent(f"""\ + source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}") + if is_framework_13_ryzen_ai_300 "{dmi_dir}"; then echo "GATE=yes"; else echo "GATE=no"; fi + exit 0 + """) + r = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10) + m = re.search(r"^GATE=(yes|no)$", r.stdout, re.M) + assert m, "no GATE line in output: %r / %r" % (r.stdout, r.stderr) + return m.group(1) == "yes" + + +def run(confd, dmi_dir, src_root=REPO_ROOT): + # The step sends install's output to $logfile; the fakes report on stdout + # so the test can see the warning and the function's own return code. + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + username=nobody + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + user_archsetup_dir="{src_root}" + source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}") + source <(sed -n '/^configure_framework_amd_internal_mic() {{/,/^}}/p' "{ARCHSETUP}") + configure_framework_amd_internal_mic "{confd}" "{dmi_dir}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class Framework13RyzenAi300Gate(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dmi = os.path.join(self.tmp.name, "dmi") + + def tearDown(self): + self.tmp.cleanup() + + # ------------------------------------------------------------ normal ---- + def test_matches_the_framework_13_on_ryzen_ai_300(self): + write_dmi(self.dmi, VELOX) + self.assertTrue(gate(self.dmi)) + + # ---------------------------------------------------------- boundary ---- + def test_rejects_the_framework_desktop_on_ryzen_ai_max(self): + """ratio: same vendor, "AMD Ryzen AI" in the name, no internal mic. + + "AMD Ryzen AI Max 300" must not satisfy an "AMD Ryzen AI 300" match. + """ + write_dmi(self.dmi, RATIO) + self.assertFalse(gate(self.dmi)) + + def test_rejects_the_framework_13_on_intel(self): + """Intel's SOF stack has no ACP PDM path; the bug does not exist there.""" + write_dmi(self.dmi, INTEL_13) + self.assertFalse(gate(self.dmi)) + + def test_rejects_the_framework_13_on_ryzen_7040(self): + """Phoenix is not Ryzen AI 300; the workaround is unverified there.""" + write_dmi(self.dmi, AMD_7040_13) + self.assertFalse(gate(self.dmi)) + + def test_rejects_the_framework_16(self): + write_dmi(self.dmi, AMD_16) + self.assertFalse(gate(self.dmi)) + + def test_rejects_another_vendor_with_the_same_model_string(self): + write_dmi(self.dmi, OTHER_VENDOR) + self.assertFalse(gate(self.dmi)) + + def test_rejects_a_missing_dmi_directory(self): + self.assertFalse(gate(os.path.join(self.tmp.name, "nope"))) + + +class ConfigureFrameworkAmdInternalMic(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dmi = os.path.join(self.tmp.name, "dmi") + self.confd = os.path.join(self.tmp.name, "wireplumber.conf.d") + self.dropin = os.path.join(self.confd, DROPIN_NAME) + + def tearDown(self): + self.tmp.cleanup() + + # ------------------------------------------------------------ normal ---- + def test_framework_13_ryzen_ai_300_gets_the_shipped_dropin(self): + write_dmi(self.dmi, VELOX) + r = run(self.confd, self.dmi) + self.assertEqual(rc_of(r), 0, r.stdout) + self.assertTrue(os.path.exists(self.dropin), "drop-in not installed") + with open(self.dropin) as f, open(DROPIN_SRC) as src: + self.assertEqual(f.read(), src.read(), + "the installed file must be the shipped file, byte for byte") + + def test_dropin_mode_is_0644(self): + """Owner-writable, world-readable, like every file under /etc.""" + write_dmi(self.dmi, VELOX) + run(self.confd, self.dmi) + mode = stat.S_IMODE(os.stat(self.dropin).st_mode) + self.assertEqual(mode, 0o644) + + def test_shipped_dropin_turns_ucm_off(self): + """Pin the shipped content, not just the copy: use-ucm must be an + active setting, not a commented example.""" + with open(DROPIN_SRC) as f: + body = f.read() + active = [ln for ln in body.splitlines() + if "alsa.use-ucm" in ln and not ln.lstrip().startswith("#")] + self.assertEqual(len(active), 1, body) + self.assertRegex(active[0], r"alsa\.use-ucm\s*=\s*false") + + def test_shipped_dropin_disables_the_standalone_pdm_card(self): + """Without UCM the broken DMIC still shows up as its own card; the + rule has to catch it and disable it, or an app or the default + selection can still land on the full-scale rail. + + The gate fires on every Framework 13 on Ryzen AI 300, so the rule + must match the card by name pattern (WirePlumber's ~regex form), not + by the one PCI path velox happens to enumerate: a renumbered bus on + the same board would otherwise get UCM turned off with the broken + card left selectable. WirePlumber's regex match is unanchored, so + re.search mirrors it. + """ + with open(DROPIN_SRC) as f: + body = f.read() + m = re.search(r'device\.name\s*=\s*"~([^"]+)"', body) + self.assertIsNotNone(m, "the rule must match device.name with a ~regex, " + "not a literal PCI path") + assert m is not None # narrows the type; the assertion above is the test + pattern = m.group(1) + self.assertTrue(re.search(pattern, VELOX_PDM_CARD), + f"{pattern!r} must match velox's card {VELOX_PDM_CARD!r}") + self.assertTrue(re.search(pattern, "alsa_card.pci-0000_c3_00.5-platform-acp-pdm-mach"), + "the same card on another bus number must match too") + self.assertFalse(re.search(pattern, VELOX_CODEC_CARD), + "the ALC285 codec card carries the working mic and must not be disabled") + self.assertRegex(body, r"device\.disabled\s*=\s*true") + + # ---------------------------------------------------------- boundary ---- + def test_other_hardware_writes_nothing(self): + for fixture in (RATIO, INTEL_13, AMD_7040_13, AMD_16, OTHER_VENDOR): + with self.subTest(product=fixture["product_name"], vendor=fixture["sys_vendor"]): + write_dmi(self.dmi, fixture) + r = run(self.confd, self.dmi) + self.assertEqual(rc_of(r), 0, r.stdout) + self.assertFalse(os.path.exists(self.confd), + "no conf.d may be created on hardware the step does not apply to") + self.assertNotIn("WARN:", r.stdout) + + def test_missing_dmi_directory_is_a_quiet_no_op(self): + """A VM or a stripped sysfs reports no DMI; that is not an error.""" + r = run(self.confd, os.path.join(self.tmp.name, "nope")) + self.assertEqual(rc_of(r), 0, r.stdout) + self.assertFalse(os.path.exists(self.confd)) + self.assertNotIn("WARN:", r.stdout) + + def test_running_twice_leaves_one_identical_dropin(self): + write_dmi(self.dmi, VELOX) + run(self.confd, self.dmi) + with open(self.dropin) as f: + first = f.read() + r = run(self.confd, self.dmi) + self.assertEqual(rc_of(r), 0, r.stdout) + with open(self.dropin) as f: + second = f.read() + self.assertEqual(first, second) + self.assertEqual(os.listdir(self.confd), [DROPIN_NAME]) + + def test_absent_confd_is_created(self): + """wireplumber's package ships /etc/wireplumber/ but not conf.d/; the + step must create the nested path rather than fail on it.""" + write_dmi(self.dmi, VELOX) + nested = os.path.join(self.tmp.name, "etc", "wireplumber", "wireplumber.conf.d") + r = run(nested, self.dmi) + self.assertEqual(rc_of(r), 0, r.stdout) + self.assertTrue(os.path.exists(os.path.join(nested, DROPIN_NAME))) + + # ------------------------------------------------------------- error ---- + def test_missing_shipped_file_warns_and_does_not_crash(self): + """A partial checkout has no configs/ tree; surface it, keep going.""" + write_dmi(self.dmi, VELOX) + empty_root = os.path.join(self.tmp.name, "empty-checkout") + os.makedirs(empty_root) + r = run(self.confd, self.dmi, src_root=empty_root) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + self.assertFalse(os.path.exists(self.dropin)) + + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_confd_warns_and_does_not_crash(self): + write_dmi(self.dmi, VELOX) + os.makedirs(self.confd) + os.chmod(self.confd, 0o500) + try: + r = run(self.confd, self.dmi) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + finally: + os.chmod(self.confd, 0o700) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_configure_hibernate_delay.py b/tests/installer-steps/test_configure_hibernate_delay.py new file mode 100644 index 0000000..95f2dcb --- /dev/null +++ b/tests/installer-steps/test_configure_hibernate_delay.py @@ -0,0 +1,131 @@ +"""Test configure_hibernate_delay — the suspend-to-hibernate handoff. + +The desktop panel can upgrade its idle suspend stage to +suspend-then-hibernate, but the delay between the two is systemd's +HibernateDelaySec, which lives in a root-owned drop-in and so cannot be a +panel setting. Left unset the delay depends on the hardware: a machine +with a battery hibernates on a low-battery alarm at an hour nobody chose, +and one without falls back to systemd's 2h default. + +90 minutes is the seeded value: long enough that stepping out of a meeting +costs a screen unlock rather than a passphrase and a resume, short enough +that a laptop in a bag is not holding its encryption keys in RAM all +afternoon (a suspended machine is; a hibernated one is not). + +Method: sed-extract configure_hibernate_delay from the real `archsetup`, +point it at a temp config dir, and assert on the file it writes. + + python3 -m unittest tests.installer-steps.test_configure_hibernate_delay +""" + +import os +import re +import stat +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(confdir): + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^configure_hibernate_delay() {{/,/^}}/p' "{ARCHSETUP}") + configure_hibernate_delay "{confdir}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +def body(confdir): + with open(os.path.join(confdir, "60-hibernate-delay.conf")) as f: + return f.read() + + +class ConfigureHibernateDelay(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_a_sleep_drop_in_with_the_delay(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + text = body(d) + self.assertIn("[Sleep]", text) + self.assertIn("HibernateDelaySec=90min", text) + + def test_drop_in_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE( + os.stat(os.path.join(d, "60-hibernate-delay.conf")).st_mode) + self.assertEqual(mode, 0o644) + + def test_explains_why_the_value_is_not_left_to_systemd(self): + # The number is a judgement call, so the file has to carry its + # reasoning: whoever changes it next needs the tradeoff, not just + # a number to overwrite. Assert on the tradeoff's own terms — + # "contains a hash" would pass on any comment at all. + with tempfile.TemporaryDirectory() as d: + run(d) + text = body(d).lower() + self.assertIn("2h default", text) # what unset would give + self.assertIn("encryption keys", text) # why not longer + self.assertIn("passphrase", text) # why not shorter + + def test_drop_in_sorts_above_vendor_drop_ins(self): + # systemd reserves 10-40 for /usr and 60-90 for /etc. A 10- file + # in /etc sorts below a vendor 20- file and silently loses to it. + with tempfile.TemporaryDirectory() as d: + run(d) + name = os.listdir(d)[0] + prefix = int(name.split("-")[0]) + self.assertGreaterEqual(prefix, 60) + self.assertLessEqual(prefix, 90) + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_correct_drop_in(self): + with tempfile.TemporaryDirectory() as d: + run(d) + first = body(d) + r = run(d) + self.assertEqual(rc_of(r), 0) + self.assertEqual(first, body(d)) + + def test_absent_directory_is_created(self): + with tempfile.TemporaryDirectory() as d: + nested = os.path.join(d, "etc", "systemd", "sleep.conf.d") + r = run(nested) + self.assertEqual(rc_of(r), 0) + self.assertIn("HibernateDelaySec=90min", body(nested)) + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_directory_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + confdir = os.path.join(d, "ro") + os.mkdir(confdir) + os.chmod(confdir, 0o500) + try: + r = run(confdir) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + finally: + os.chmod(confdir, 0o700) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_configure_openai_chatgpt_repo.py b/tests/installer-steps/test_configure_openai_chatgpt_repo.py new file mode 100644 index 0000000..cb0e84b --- /dev/null +++ b/tests/installer-steps/test_configure_openai_chatgpt_repo.py @@ -0,0 +1,324 @@ +"""Test configure_openai_chatgpt_repo — OpenAI's signed pacman repo for ChatGPT. + +OpenAI ships the ChatGPT (Codex) desktop app for Arch as chatgpt-bin from +its own signed pacman repository. Its install-arch.sh pins the repository +signing-key fingerprint, locally signs the key into the pacman keyring, +writes /etc/pacman.d/openai-chatgpt.conf and adds an Include line to +pacman.conf; ratio and velox were set up that way on 2026-10-11. The step +reproduces exactly that configuration so a re-run on those machines is a +no-op and the app rides normal pacman upgrades under OpenAI's signature. +The AUR repackage of the same binary (chatgpt-desktop) would conflict with +chatgpt-bin on any machine already using the repo, so it is not used. + +A half-configured repository is worse than none: with the Include in place +and the key untrusted, SigLevel TrustedOnly makes every later pacman call +fail on the invalid database. So an untrusted key (fingerprint mismatch or +failed download) must import nothing and leave pacman.conf untouched. + +The pin is on the primary key's fingerprint alone. pacman-key --lsign-key +resolves a subkey fingerprint to its primary, so a bundle whose subkey or +second primary carries the pinned fingerprint must be rejected too. + +Method: sed-extract configure_openai_chatgpt_repo from the real `archsetup`, +point it at a temp pacman.conf and repo drop-in, and replace curl, gpg, +pacman-key and pacman with recorders driven by environment switches. + + python3 -m unittest tests.installer-steps.test_configure_openai_chatgpt_repo +""" + +import os +import re +import stat +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") + +FPR = "3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4" +KEY_URL = "https://persistent.oaistatic.com/codex-app-prod/linux/repository-signing-key.gpg" +SERVER = "https://persistent.oaistatic.com/codex-app-prod/linux/arch/$arch" +BASE_CONF = "[core]\nInclude = /etc/pacman.d/mirrorlist\n" + +# Recorders. Every fake appends its argv to $MARKER; the pacman fake also +# records whether the Include line is already in pacman.conf at call time, +# which is how the tests see that -Sy ran after the repo was wired in. +STUBS = r""" +logfile=/dev/null +action="" +display() { :; } +backup_system_file() { :; } +error_warn() { echo "WARN: $1"; return 1; } +pacman-key() { + echo "PACMAN_KEY: $*" >> "$MARKER" + case "$1" in + --list-keys) [ "$KEY_PRESENT" = 1 ] ;; + *) return 0 ;; + esac +} +curl() { + echo "CURL: $*" >> "$MARKER" + [ "$CURL_FAIL" = 1 ] && return 22 + local out="" + while [ $# -gt 0 ]; do + case "$1" in -o) out="$2"; shift ;; esac + shift + done + [ -n "$out" ] && printf 'FAKE KEY\n' > "$out" + return 0 +} +gpg() { + echo "GPG: $*" >> "$MARKER" + case " $* " in + *" --show-keys "*) + printf 'pub:u:4096:1:4A3B4A566C4660E4:1754362069:::u:::scSC::::::23::0:\n' + printf 'fpr:::::::::%s:\n' "$SERVED_FPR" + printf 'uid:u::::1754362069::ABC::Codex Linux Repository::::::::::0:\n' + if [ -n "$SERVED_SUBKEY_FPR" ]; then + printf 'sub:u:4096:1:0123456789ABCDEF:1754362069::::::e::::::23:\n' + printf 'fpr:::::::::%s:\n' "$SERVED_SUBKEY_FPR" + fi + if [ -n "$SERVED_SECOND_PUB_FPR" ]; then + printf 'pub:u:4096:1:FEDCBA9876543210:1754362069:::u:::scSC::::::23::0:\n' + printf 'fpr:::::::::%s:\n' "$SERVED_SECOND_PUB_FPR" + printf 'uid:u::::1754362069::DEF::Somebody Else::::::::::0:\n' + fi + ;; + esac + return 0 +} +pacman() { + local n + n=$(grep -cxF "Include = $REPO_CONF" "$PACMAN_CONF" 2>/dev/null) + echo "PACMAN: $* include=$n" >> "$MARKER" + return 0 +} +""" + + +def paths(d): + return (os.path.join(d, "pacman.conf"), + os.path.join(d, "pacman.d", "openai-chatgpt.conf"), + os.path.join(d, "calls")) + + +def run(d, *, key_present=False, served_fpr=FPR, served_subkey_fpr="", + served_second_pub_fpr="", curl_fail=False, pacman_conf_body=BASE_CONF): + pacman_conf, repo_conf, marker = paths(d) + if pacman_conf_body is not None and not os.path.exists(pacman_conf): + with open(pacman_conf, "w") as f: + f.write(pacman_conf_body) + script = STUBS + textwrap.dedent(f"""\ + source <(sed -n '/^configure_openai_chatgpt_repo() {{/,/^}}/p' "{ARCHSETUP}") + configure_openai_chatgpt_repo "$PACMAN_CONF" "$REPO_CONF" + echo "RC=$?" + exit 0 + """) + env = dict(os.environ, MARKER=marker, PACMAN_CONF=pacman_conf, + REPO_CONF=repo_conf, KEY_PRESENT="1" if key_present else "0", + SERVED_FPR=served_fpr, SERVED_SUBKEY_FPR=served_subkey_fpr, + SERVED_SECOND_PUB_FPR=served_second_pub_fpr, + CURL_FAIL="1" if curl_fail else "0") + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=10, env=env) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +def read(path): + with open(path) as f: + return f.read() + + +def calls(d): + marker = paths(d)[2] + return read(marker) if os.path.exists(marker) else "" + + +class ConfigureOpenaiChatgptRepo(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_the_published_repo_definition(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + _, repo_conf, _ = paths(d) + self.assertTrue(os.path.exists(repo_conf)) + body = read(repo_conf) + self.assertIn("[openai-chatgpt]\n", body) + self.assertIn("SigLevel = Required DatabaseRequired TrustedOnly\n", body) + # $arch stays literal: pacman expands it, the shell must not. + self.assertIn(f"Server = {SERVER}\n", body) + + def test_repo_definition_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE(os.stat(paths(d)[1]).st_mode) + self.assertEqual(mode, 0o644) + + def test_adds_one_include_line_to_pacman_conf(self): + with tempfile.TemporaryDirectory() as d: + run(d) + pacman_conf, repo_conf, _ = paths(d) + body = read(pacman_conf) + self.assertTrue(body.startswith(BASE_CONF), body) + self.assertEqual(body.count(f"\nInclude = {repo_conf}\n"), 1) + + def test_imports_and_locally_signs_the_pinned_key(self): + with tempfile.TemporaryDirectory() as d: + run(d) + c = calls(d) + self.assertIn(f" {KEY_URL}", c) + self.assertIn("GPG: ", c) + self.assertIn("--show-keys", c) + self.assertIn("PACMAN_KEY: --add ", c) + self.assertIn(f"PACMAN_KEY: --lsign-key {FPR}", c) + + def test_refreshes_databases_after_the_include_is_in_place(self): + with tempfile.TemporaryDirectory() as d: + run(d) + self.assertIn("PACMAN: -Sy include=1", calls(d)) + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_include_and_the_same_files(self): + with tempfile.TemporaryDirectory() as d: + pacman_conf, repo_conf, _ = paths(d) + self.assertEqual(rc_of(run(d)), 0) + first_conf, first_repo = read(pacman_conf), read(repo_conf) + self.assertEqual(rc_of(run(d)), 0) + self.assertEqual(read(pacman_conf), first_conf) + self.assertEqual(read(repo_conf), first_repo) + self.assertEqual(first_conf.count("Include = " + repo_conf), 1) + + def test_already_trusted_key_is_not_downloaded_or_added_again(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, key_present=True) + self.assertEqual(rc_of(r), 0) + c = calls(d) + self.assertNotIn("CURL:", c) + self.assertNotIn("PACMAN_KEY: --add", c) + self.assertTrue(os.path.exists(paths(d)[1])) + + def test_pacman_conf_without_trailing_newline_gets_its_own_line(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, pacman_conf_body=BASE_CONF.rstrip("\n")) + self.assertEqual(rc_of(r), 0) + pacman_conf, repo_conf, _ = paths(d) + body = read(pacman_conf) + self.assertIn("Include = /etc/pacman.d/mirrorlist\n", body) + self.assertRegex(body, re.compile(rf"^Include = {re.escape(repo_conf)}$", re.M)) + + def test_existing_differing_repo_definition_is_kept_and_flagged(self): + with tempfile.TemporaryDirectory() as d: + pacman_conf, repo_conf, _ = paths(d) + os.makedirs(os.path.dirname(repo_conf)) + custom = "[openai-chatgpt]\nSigLevel = Required\nServer = file:///srv/mirror\n" + with open(repo_conf, "w") as f: + f.write(custom) + r = run(d) + self.assertIn("WARN:", r.stdout) + self.assertEqual(read(repo_conf), custom) + # The repository is still configured, just by the user's file. + self.assertIn(f"\nInclude = {repo_conf}\n", read(pacman_conf)) + self.assertEqual(rc_of(r), 0) + + # ------------------------------------------------------------- error ---- + def assert_nothing_configured(self, d, r): + pacman_conf, repo_conf, _ = paths(d) + c = calls(d) + self.assertIn("WARN:", r.stdout) + self.assertNotIn("PACMAN_KEY: --add", c) + self.assertNotIn("PACMAN_KEY: --lsign-key", c) + self.assertNotIn("PACMAN: -Sy", c) + self.assertFalse(os.path.exists(repo_conf)) + self.assertEqual(read(pacman_conf), BASE_CONF) + self.assertNotEqual(rc_of(r), 0) + + def test_fingerprint_mismatch_imports_nothing_and_touches_nothing(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr="DEADBEEF" * 5) + self.assert_nothing_configured(d, r) + + def test_key_download_failure_imports_nothing_and_touches_nothing(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, curl_fail=True) + self.assert_nothing_configured(d, r) + + # The pin is on the PRIMARY key's fingerprint and nothing else. pacman-key + # --lsign-key resolves a subkey fingerprint to the primary that carries it, + # so a pin that a subkey or a second primary could satisfy would trust + # whatever key the bundle smuggled in beside the expected one. + def test_subkey_carrying_the_pinned_fingerprint_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr="DEADBEEF" * 5, served_subkey_fpr=FPR) + self.assert_nothing_configured(d, r) + + def test_second_primary_carrying_the_pinned_fingerprint_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr="DEADBEEF" * 5, served_second_pub_fpr=FPR) + self.assert_nothing_configured(d, r) + + def test_rogue_second_primary_beside_the_pinned_key_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr=FPR, served_second_pub_fpr="DEADBEEF" * 5) + self.assert_nothing_configured(d, r) + + @unittest.skipUnless(os.geteuid() != 0, "root ignores file write bits") + def test_unwritable_pacman_conf_warns_and_reports_failure(self): + with tempfile.TemporaryDirectory() as d: + pacman_conf, _, _ = paths(d) + with open(pacman_conf, "w") as f: + f.write(BASE_CONF) + os.chmod(pacman_conf, 0o444) + try: + r = run(d) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + self.assertNotIn("PACMAN: -Sy", calls(d)) + finally: + os.chmod(pacman_conf, 0o644) + + +class SupplementalSoftwareWiring(unittest.TestCase): + """chatgpt-bin is installed only once its repository is trusted.""" + + def run_step(self, repo_rc): + script = textwrap.dedent(f"""\ + desktop_env=hyprland + display() {{ :; }} + run_task() {{ :; }} + mask_fwupd_passim() {{ :; }} + pacman_install() {{ echo "PACMAN_INSTALL: $1"; }} + aur_install() {{ echo "AUR_INSTALL: $1"; }} + configure_openai_chatgpt_repo() {{ echo "CONFIGURE_REPO"; return {repo_rc}; }} + source <(sed -n '/^supplemental_software() {{/,/^}}/p' "{ARCHSETUP}") + supplemental_software + """) + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=10) + + def test_installs_chatgpt_bin_after_configuring_the_repo(self): + out = self.run_step(0).stdout + self.assertIn("CONFIGURE_REPO\n", out) + self.assertLess(out.index("CONFIGURE_REPO"), + out.index("PACMAN_INSTALL: chatgpt-bin")) + + def test_skips_chatgpt_bin_when_the_repo_cannot_be_trusted(self): + out = self.run_step(1).stdout + self.assertNotIn("PACMAN_INSTALL: chatgpt-bin", out) + # The failure is local to the app; the rest of the step still runs. + self.assertIn("AUR_INSTALL: claude-desktop", out) + self.assertIn("AUR_INSTALL: freetube", out) + + def test_does_not_also_pull_the_conflicting_aur_repackage(self): + out = self.run_step(0).stdout + self.assertNotIn("chatgpt-desktop", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_configure_service_discovery.py b/tests/installer-steps/test_configure_service_discovery.py new file mode 100644 index 0000000..90ce041 --- /dev/null +++ b/tests/installer-steps/test_configure_service_discovery.py @@ -0,0 +1,85 @@ +"""Pin configure_service_discovery's source: the WS-Discovery host daemon +is never enabled. + +wsdd.service advertises this machine as a Samba host to Windows clients. +Nothing the installer sets up runs Samba, so enabled it advertised a share +server that doesn't exist while listening on every interface, VPN and +tailscale links included (2026-09-12). Browsing Windows shares is the other +direction: gvfs-wsdd spawns its own wsdd in discovery mode and needs only +the package. + +Method: the step writes straight to /etc (geoclue.conf, the dbus-broker +drop-in) with no directory parameter, so unlike the other step tests this +one can't run the function in a temp dir. It sed-extracts the source and +asserts on the calls it contains. + + python3 -m unittest tests.installer-steps.test_configure_service_discovery +""" + +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") + + +def function_source(name): + with open(ARCHSETUP) as f: + text = f.read() + m = re.search(r"^%s\(\) \{\n.*?^\}\n" % re.escape(name), text, re.S | re.M) + assert m, "function %s not found in archsetup" % name + return m.group(0) + + +ENABLE_WSDD = r"(systemctl\s+enable|enable_service)\s+(--now\s+)?wsdd\b" + + +def calls(src): + """Non-comment lines — what the function actually runs.""" + return [l for l in src.splitlines() if l.strip() and not l.strip().startswith("#")] + + +class ConfigureServiceDiscovery(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_does_not_enable_the_wsdd_host_daemon(self): + # Both spellings the script uses: a raw systemctl call and the + # enable_service helper (which takes the bare unit name). + body = "\n".join(calls(function_source("configure_service_discovery"))) + self.assertNotRegex(body, ENABLE_WSDD) + + def test_turns_off_a_previously_enabled_wsdd_on_rerun(self): + # Machines installed before this change still have the unit + # enabled; a re-run converges them. The guard keeps a fresh install + # (no unit yet) quiet. + body = "\n".join(calls(function_source("configure_service_discovery"))) + self.assertRegex(body, r"systemctl\s+is-enabled\s+(--quiet\s+)?wsdd\.service") + self.assertRegex(body, r"systemctl\s+disable\s+--now\s+wsdd\.service") + + def test_still_enables_the_discovery_it_does_want(self): + # Characterization: removing wsdd must not have taken avahi (mDNS) + # or geoclue with it. + body = "\n".join(calls(function_source("configure_service_discovery"))) + self.assertIn("systemctl enable avahi-daemon.service", body) + self.assertIn("systemctl enable geoclue.service", body) + + # ---------------------------------------------------------- boundary ---- + def test_wsdd_package_still_installed_for_gvfs(self): + # The package stays: gvfs-wsdd depends on it and spawns its own + # discovery-mode instance. Only the host service is gone. + body = "\n".join(calls(function_source("supplemental_software"))) + self.assertRegex(body, r"pacman_install\s+wsdd\b") + self.assertRegex(body, r"pacman_install\s+gvfs-wsdd\b") + + # ------------------------------------------------------------- error ---- + def test_no_other_step_enables_wsdd_either(self): + # A regression that re-enables it from a different step is the same + # bug; scan the whole script, not just the one function. + with open(ARCHSETUP) as f: + lines = [l for l in f if l.strip() and not l.strip().startswith("#")] + offenders = [l.rstrip() for l in lines if re.search(ENABLE_WSDD, l)] + self.assertEqual(offenders, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_configure_tlp_power.py b/tests/installer-steps/test_configure_tlp_power.py new file mode 100644 index 0000000..dc46c93 --- /dev/null +++ b/tests/installer-steps/test_configure_tlp_power.py @@ -0,0 +1,239 @@ +"""Test configure_tlp_power's radio-enable line, daemon masking, and laptop gating. + +systemd-rfkill is masked on laptops because it fights TLP's radio handling — +which means nothing restores radio state at boot unless TLP is told to. The +velox 2026-04-10 setup found wifi and bluetooth soft-blocked on first boot for +exactly this reason. The conf written here must carry +DEVICES_TO_ENABLE_ON_STARTUP so a fresh install comes up with radios on. + +power-profiles-daemon is masked and stopped on laptops for the same class of +reason. power-profiles-daemon.service declares "Conflicts=tuned.service +tlp.service auto-cpufreq.service ..." — the line is in ppd's unit, not tlp's — +so systemd TERMs TLP the instant ppd starts. Leaving ppd merely disabled does +not prevent that: ppd ships D-Bus activation files, and the desktop-settings +panel's own powerprofilesctl call activates it on demand. Velox ran that way +from its 2026-08-13 rebuild until 2026-08-16, with TLP failing at every boot and +none of its battery policy applied, while the machine looked correctly +configured. Masking blocks D-Bus activation too, which both keeps TLP alive and +makes the panel's power control read as unavailable, the behavior the +package-install site in `archsetup` already documents as intended. The stop is +what makes a repair re-run take effect on a booted machine, where a mask alone +would leave a running ppd running. + +The bluetooth resume hook installed here must land in +/usr/lib/systemd/system-sleep/. That is the only directory systemd-sleep scans +(systemd 262's binary and its man page both name just the one), so a hook under +/etc/systemd/system-sleep/ is silently dead. Velox ran about fifteen hibernate +cycles between 2026-08-21 and 2026-10-05 with the hook installed there and it +never fired once; bluetooth came back rfkilled every time, and TLP's own hook +(which lives in /usr/lib and does run) carried the block across every later +cycle. + +Method: sed-extract configure_tlp_power from the real `archsetup`, point it at +a temp tlp.d dir and a temp power-supply dir, and fake pacman_install / +run_task / display / error_warn / systemctl. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_configure_tlp_power +""" + +import os +import stat +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(battery=True, bat_name="BAT0", unwritable_tlpd=False, systemctl_fails=False, + install_fails=False): + with tempfile.TemporaryDirectory() as d: + psdir = os.path.join(d, "power_supply") + os.makedirs(psdir) + if battery: + open(os.path.join(psdir, bat_name), "w").close() + tlpd = os.path.join(d, "tlp.d") + if unwritable_tlpd: + os.makedirs(tlpd) + os.chmod(tlpd, stat.S_IRUSR | stat.S_IXUSR) + # The real mask call redirects stdout into $logfile, so the fake + # systemctl records to a side file the test reads back instead. + sysrc = 1 if systemctl_fails else 0 + # The real install_executable warns on its own; the fake only reports + # the return code so the test can see whether the step keeps going. + instrc = 1 if install_fails else 0 + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + pacman_install() {{ echo "INSTALL: $1"; }} + run_task() {{ echo "TASK: $1"; }} + systemctl() {{ echo "SYSTEMCTL: $*" >> "{d}/systemctl.log"; return {sysrc}; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + user_archsetup_dir=/src + install_executable() {{ echo "INSTALL_EXEC: $1 -> $2"; return {instrc}; }} + source <(sed -n '/^configure_tlp_power() {{/,/^}}/p' "{ARCHSETUP}") + configure_tlp_power "{tlpd}" "{psdir}" + echo "RC=$?" + echo "CONF:[$(cat "{tlpd}/01-custom.conf" 2>/dev/null)]" + [ -f "{d}/systemctl.log" ] && cat "{d}/systemctl.log" + exit 0 + """) + r = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + if unwritable_tlpd: + os.chmod(tlpd, stat.S_IRWXU) + return r + + +class ConfigureTlpPower(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_laptop_gets_tlp_with_radio_enable_line(self): + r = run(battery=True) + self.assertIn("INSTALL: tlp", r.stdout) + conf = r.stdout.split("CONF:[")[1] + self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"', conf, + "radios must be re-enabled at boot: systemd-rfkill is " + "masked, so TLP is the only thing that can do it") + self.assertIn("CPU_ENERGY_PERF_POLICY_ON_AC", conf) + self.assertIn("SYSTEMCTL: mask systemd-rfkill.service systemd-rfkill.socket", + r.stdout) + self.assertIn("TASK: enabling TLP service", r.stdout) + + def test_laptop_masks_power_profiles_daemon(self): + r = run(battery=True) + self.assertIn("SYSTEMCTL: mask power-profiles-daemon.service", r.stdout, + "ppd's unit declares Conflicts=...tlp.service..., so ppd " + "must be masked or it TERMs TLP whenever it is activated") + + def test_laptop_stops_running_power_profiles_daemon(self): + """Masking alone leaves an already-running ppd running. + + The installer runs on a booted system, so a repair re-run would + otherwise mask ppd, leave it live, and let it keep TLP dead until the + next reboot with nothing reporting it. + """ + r = run(battery=True) + self.assertIn("SYSTEMCTL: stop power-profiles-daemon.service", r.stdout) + + def test_ppd_is_masked_before_it_is_stopped(self): + """Order matters: stopping first leaves a window to re-activate in.""" + calls = [line for line in run(battery=True).stdout.splitlines() + if line.startswith("SYSTEMCTL:") and "power-profiles-daemon" in line] + verbs = [line.split()[1] for line in calls] + self.assertEqual(verbs, ["mask", "stop"]) + + def test_power_profiles_daemon_is_masked_not_merely_disabled(self): + """Disabling ppd is not enough — D-Bus activation ignores it. + + This is the whole point of the mask, so assert the verb directly. A + `disable` here would pass a naive "ppd is handled" check while leaving + the panel's powerprofilesctl call free to start ppd and kill TLP. + """ + r = run(battery=True) + ppd_calls = [line for line in r.stdout.splitlines() + if line.startswith("SYSTEMCTL:") and "power-profiles-daemon" in line] + self.assertTrue(ppd_calls, "configure_tlp_power must act on ppd at all") + for line in ppd_calls: + self.assertNotIn(" disable ", line, + "disable leaves D-Bus activation live; only mask blocks it") + + def test_radio_line_is_active_not_commented(self): + r = run(battery=True) + conf = r.stdout.split("CONF:[")[1].split("]")[0] + for line in conf.splitlines(): + if "DEVICES_TO_ENABLE_ON_STARTUP" in line: + self.assertFalse(line.lstrip().startswith("#"), + "the radio-enable line must not be commented out") + break + else: + self.fail("DEVICES_TO_ENABLE_ON_STARTUP line missing from conf") + + def test_laptop_installs_the_resume_hook_where_systemd_sleep_scans(self): + """The hook goes to /usr/lib/systemd/system-sleep/, the only scanned dir. + + Assert the full install list, not a substring: a second copy under + /etc/ would pass an assertIn and still be the dead hook this test + exists to catch. + """ + r = run(battery=True) + installs = [line for line in r.stdout.splitlines() + if line.startswith("INSTALL_EXEC:")] + self.assertEqual( + installs, + ["INSTALL_EXEC: /src/scripts/zz-bluetooth-resume -> " + "/usr/lib/systemd/system-sleep/zz-bluetooth-resume"], + "systemd-sleep never scans /etc/systemd/system-sleep/; a hook " + "there installs cleanly and never runs") + + # ---------------------------------------------------------- boundary ---- + def test_desktop_installs_no_resume_hook(self): + """No TLP on a desktop means no masked rfkill and no gap to fill.""" + r = run(battery=False) + self.assertNotIn("INSTALL_EXEC:", r.stdout) + + def test_desktop_without_battery_is_a_no_op(self): + r = run(battery=False) + self.assertNotIn("INSTALL:", r.stdout) + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertIn("CONF:[]", r.stdout, "no conf may be written on a desktop") + + def test_second_battery_index_still_counts_as_laptop(self): + r = run(battery=True, bat_name="BAT1") + self.assertIn("INSTALL: tlp", r.stdout) + self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP', r.stdout) + + def test_desktop_keeps_power_profiles_daemon(self): + """A batteryless machine must NOT get ppd masked. + + There is no TLP on a desktop to conflict with it, and the package-install + site enables ppd precisely so the settings panel's three-way power + control works there. Masking it here would break that control for no gain. + """ + r = run(battery=False) + self.assertNotIn("power-profiles-daemon", r.stdout) + + # ------------------------------------------------------------- error ---- + def test_failed_ppd_mask_warns_and_does_not_crash(self): + """A masking failure must surface, not pass silently. + + Silence is the exact failure mode being fixed: velox looked configured + while TLP was dead. If the mask cannot be applied, say so. + + Assert on the harness's own RC= line, not on r.returncode. The harness + script ends in a literal `exit 0`, so r.returncode is 0 no matter what + configure_tlp_power does — asserting it can never fail, which would make + this test the same silent no-op it exists to catch. + """ + r = run(battery=True, systemctl_fails=True) + self.assertIn("WARN: masking power-profiles-daemon for TLP", r.stdout) + self.assertIn("WARN: stopping power-profiles-daemon for TLP", r.stdout) + self.assertIn("RC=", r.stdout, + "the function must return so the install continues, " + "not exit and take the script down with it") + + def test_failed_hook_install_does_not_stop_the_ppd_mask(self): + """install_executable warns for itself, so the step must carry on. + + The mask that follows is what keeps TLP alive; a hook copy that fails + (source missing on a partial checkout) must not take it with it. + """ + r = run(battery=True, install_fails=True) + self.assertIn("INSTALL_EXEC:", r.stdout) + self.assertIn("SYSTEMCTL: mask power-profiles-daemon.service", r.stdout) + self.assertIn("RC=", r.stdout) + + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_tlpd_warns_and_does_not_crash(self): + r = run(battery=True, unwritable_tlpd=True) + self.assertIn("WARN:", r.stdout, + "a failed conf write must surface through error_warn") + self.assertEqual(r.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_configure_tunnel_dns_over_tls.py b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py new file mode 100644 index 0000000..89677d8 --- /dev/null +++ b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py @@ -0,0 +1,147 @@ +"""Test configure_tunnel_dns_over_tls — per-link DoT off for VPN tunnels. + +The resolved drop-in pins DNSOverTLS=yes globally. Proton VPN (proton0) and +the static Proton WireGuard profiles (wgpvpn) push an in-tunnel resolver, +10.2.0.1, that answers plain port 53 and never completes TLS on 853, so +every lookup through the tunnel hangs. The Proton client recreates its NM +profile on each connect, so a per-profile setting can't stick; a +NetworkManager [connection-*] default matched on the interface names is +what NM pushes to resolved on every activation. Diagnosed 2026-09-09/10, +verified live on ratio and velox. + +Method: sed-extract configure_tunnel_dns_over_tls from the real +`archsetup`, point it at a temp conf.d, and assert on the file it writes. + + python3 -m unittest tests.installer-steps.test_configure_tunnel_dns_over_tls +""" + +import os +import re +import stat +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") +FILENAME = "tunnel-dns-over-tls.conf" + + +def run(confdir): + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^configure_tunnel_dns_over_tls() {{/,/^}}/p' "{ARCHSETUP}") + configure_tunnel_dns_over_tls "{confdir}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +def body(confdir): + with open(os.path.join(confdir, FILENAME)) as f: + return f.read() + + +def directives(text): + """The non-comment, non-blank lines — what NM actually parses.""" + return [l.strip() for l in text.splitlines() + if l.strip() and not l.lstrip().startswith("#")] + + +class ConfigureTunnelDnsOverTls(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_a_connection_default_that_turns_dot_off(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + lines = directives(body(d)) + self.assertIn("[connection-tunnel-dot]", lines) + self.assertIn("connection.dns-over-tls=0", lines) + + def test_matches_both_proton_interfaces_and_nothing_else(self): + # proton0 is the Proton client; wgpvpn is the static WireGuard + # profiles. A match-device line without both leaves one tunnel + # broken; one with a wildcard would turn DoT off for wifi too. + with tempfile.TemporaryDirectory() as d: + run(d) + match = [l for l in directives(body(d)) if l.startswith("match-device=")] + self.assertEqual(len(match), 1) + devices = match[0].split("=", 1)[1].split(",") + self.assertEqual(sorted(devices), + ["interface-name:proton0", "interface-name:wgpvpn"]) + + def test_explains_why_the_global_setting_is_overridden_per_link(self): + # The file contradicts the resolved drop-in next to it, so it has + # to carry the reason: the in-tunnel resolver that never completes + # TLS, and why the Proton client can't hold a per-profile setting. + with tempfile.TemporaryDirectory() as d: + run(d) + text = body(d).lower() + self.assertIn("10.2.0.1", text) + self.assertIn("853", text) + self.assertIn("recreates", text) + + def test_drop_in_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE(os.stat(os.path.join(d, FILENAME)).st_mode) + self.assertEqual(mode, 0o644) + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_identical_file(self): + with tempfile.TemporaryDirectory() as d: + run(d) + first = body(d) + r = run(d) + self.assertEqual(rc_of(r), 0) + self.assertEqual(first, body(d)) + self.assertEqual(os.listdir(d), [FILENAME]) + + def test_absent_directory_is_created(self): + with tempfile.TemporaryDirectory() as d: + nested = os.path.join(d, "etc", "NetworkManager", "conf.d") + r = run(nested) + self.assertEqual(rc_of(r), 0) + self.assertIn("connection.dns-over-tls=0", directives(body(nested))) + + def test_leaves_sibling_drop_ins_alone(self): + # dns.conf, wifi-privacy.conf and wifi-powersave-off.conf share the + # directory; this step must add a file, never rewrite the dir. + with tempfile.TemporaryDirectory() as d: + sibling = os.path.join(d, "dns.conf") + with open(sibling, "w") as f: + f.write("[main]\ndns=systemd-resolved\n") + run(d) + with open(sibling) as f: + self.assertEqual(f.read(), "[main]\ndns=systemd-resolved\n") + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_directory_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + confdir = os.path.join(d, "ro") + os.mkdir(confdir) + os.chmod(confdir, 0o500) + try: + r = run(confdir) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + finally: + os.chmod(confdir, 0o700) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_dotfiles_dependency_packages.py b/tests/installer-steps/test_dotfiles_dependency_packages.py new file mode 100644 index 0000000..58b7275 --- /dev/null +++ b/tests/installer-steps/test_dotfiles_dependency_packages.py @@ -0,0 +1,119 @@ +"""Pin the packages the dotfiles assume are present. + +Three packages are not optional extras: something the dotfiles ship depends +on each one, and when the package is missing the dependent silently does the +wrong thing rather than failing. + +- libreoffice-fresh :: common/.config/mimeapps.list maps presentations, + documents and spreadsheets to libreoffice-impress/-writer/-calc. With the + package absent those .desktop files don't exist, so xdg-mime falls through + to the next application claiming the type — on a machine with the winvm + dotfiles that is powerpoint.desktop, which boots a Windows VM to open a + deck (2026-09-18). +- imv :: gui-open --image execs imv, and without it every agent-side image + render fails with "required application is unavailable" (2026-09-18). +- git-lfs :: a repo tracking globs in LFS fails every checkout and merge + with "smudge filter lfs failed" (2026-09-20). + +All three were installed before the 2026-08-13 rebuild and absent after it, +which is the regression this pins: they are dependencies of shipped defaults, +so the installer has to declare them rather than leave them to whatever a +machine happens to carry. + +Method mirrors test_required_software: sed-extract supplemental_software from +the real `archsetup`, stub pacman_install as a recorder, run it, and assert +against what it actually invoked. Running the function beats matching its +source text, because the property under test is "every machine installs this" +and only a run resolves the conditionals that could make that false. The +function is run once per desktop environment for the same reason. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_dotfiles_dependency_packages +""" + +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") + +DEPENDENCY_PACKAGES = ("libreoffice-fresh", "imv", "git-lfs") + +# Every desktop environment the installer branches on inside this function. +DESKTOP_ENVS = ("dwm", "hyprland") + + +def declared_packages(desktop_env): + """Return (exit_code, [pacman package, ...]) for one desktop environment. + + Everything the function calls besides pacman_install is stubbed to a no-op, + so the run records package declarations and nothing else. aur_install is + deliberately separate: these three are pacman packages, and folding the two + recorders together would let an AUR declaration satisfy the pin. + """ + script = textwrap.dedent(f"""\ + desktop_env={desktop_env} + display() {{ :; }} + aur_install() {{ :; }} + mask_fwupd_passim() {{ :; }} + run_task() {{ :; }} + error_warn() {{ :; }} + pacman_install() {{ echo "$1"; }} + source <(sed -n '/^supplemental_software() {{/,/^}}/p' "{ARCHSETUP}") + supplemental_software + """) + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=30, + ) + return result.returncode, result.stdout.split() + + +class DotfilesDependencyPackages(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_each_dependency_package_is_installed(self): + rc, pkgs = declared_packages("hyprland") + self.assertEqual(rc, 0) + for package in DEPENDENCY_PACKAGES: + with self.subTest(package=package): + self.assertIn( + package, pkgs, + f"{package} is a dependency of a shipped dotfiles default " + "and must be declared", + ) + + # ---------------------------------------------------------- boundary ---- + def test_each_is_declared_exactly_once(self): + # A second declaration is dead weight and drifts out of sync with the + # first when one of them is edited. + rc, pkgs = declared_packages("hyprland") + self.assertEqual(rc, 0) + for package in DEPENDENCY_PACKAGES: + with self.subTest(package=package): + self.assertEqual(pkgs.count(package), 1) + + # ------------------------------------------------------------- error ---- + def test_none_is_gated_behind_a_desktop_environment(self): + # The dotfiles defaults that need these apply on every DE, so a + # declaration reachable under only one of them would leave the same + # hole on the other. + for env in DESKTOP_ENVS: + rc, pkgs = declared_packages(env) + self.assertEqual(rc, 0) + for package in DEPENDENCY_PACKAGES: + with self.subTest(desktop_env=env, package=package): + self.assertIn(package, pkgs) + + def test_harness_observes_desktop_environment_gating(self): + # The control for the test above: ranger IS gated to dwm on purpose, so + # if this run can't see that, the DE-independence assertion is vacuous + # and would pass against a gated package too. + _, dwm = declared_packages("dwm") + _, hyprland = declared_packages("hyprland") + self.assertIn("ranger", dwm) + self.assertNotIn("ranger", hyprland) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_idempotency_cluster.py b/tests/installer-steps/test_idempotency_cluster.py index ecb279d..0cca750 100644 --- a/tests/installer-steps/test_idempotency_cluster.py +++ b/tests/installer-steps/test_idempotency_cluster.py @@ -114,6 +114,33 @@ class EnableUserService(unittest.TestCase): out = run("enable_user_service", one).stdout self.assertIn("RC=0", out) + def test_socket_unit_lands_in_sockets_target_wants(self): + # A socket unit's [Install] is WantedBy=sockets.target, so enabling it + # via default.target.wants would never socket-activate. The optional + # fifth arg names the wants target. + with tempfile.TemporaryDirectory() as home: + body = ( + f'enable_user_service {ME!r} podman.socket ' + f'/usr/lib/systemd/user/podman.socket {home!r} sockets.target\n' + f'link="{home}/.config/systemd/user/sockets.target.wants/podman.socket"\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/podman.socket", out) + + def test_omitted_target_still_defaults_to_default_target(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'[ -L "{home}/.config/systemd/user/default.target.wants/gamemoded.service" ] ' + f'&& echo "DEFAULT=yes" || echo "DEFAULT=no"' + ) + out = run("enable_user_service", body).stdout + self.assertIn("DEFAULT=yes", out) + if __name__ == "__main__": unittest.main() diff --git a/tests/installer-steps/test_install_camera_passthrough_rules.py b/tests/installer-steps/test_install_camera_passthrough_rules.py new file mode 100644 index 0000000..ab28575 --- /dev/null +++ b/tests/installer-steps/test_install_camera_passthrough_rules.py @@ -0,0 +1,118 @@ +"""Test install_camera_passthrough_rules — the VM camera udev grant. + +usbredirect must open a camera's raw USB node read-write to claim it for the +Windows VM; the node defaults to root-owned with no group write, so the +attach fails with a bare "Failed to open device!". The rule grants +GROUP="video", MODE="0660" (the verified fix) and keeps TAG+="uaccess". + +The file NUMBER is load-bearing (winvm correction, 2026-08-08): the uaccess +ACL is applied by 73-seat-late.rules, so a 99- file adds the tag after that +already ran. The shipped filename must sort below 73. + +Method: sed-extract install_camera_passthrough_rules from the real +`archsetup`, point it at a temp rules path, fake display / error_warn. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_camera_passthrough_rules +""" + +import os +import re +import stat +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(rules_path, pre=""): + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^install_camera_passthrough_rules() {{/,/^}}/p' "{ARCHSETUP}") + {pre} + install_camera_passthrough_rules "{rules_path}" + echo "RC=$?" + echo "RULES:[$(cat "{rules_path}" 2>/dev/null)]" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rules_body(r): + return r.stdout.split("RULES:[")[1].split("]")[0] + + +class InstallCameraPassthroughRules(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_both_camera_rules_with_group_mode_and_tag(self): + with tempfile.TemporaryDirectory() as d: + r = run(os.path.join(d, "72-test.rules")) + body = rules_body(r) + for vendor, product in (("3564", "ff02"), ("046d", "085e")): + line = next((ln for ln in body.splitlines() + if f'ATTR{{idVendor}}=="{vendor}"' in ln), None) + assert line is not None, f"no rule line for {vendor}:{product}" + self.assertIn(f'ATTR{{idProduct}}=="{product}"', line) + self.assertIn('GROUP="video"', line) + self.assertIn('MODE="0660"', line) + self.assertIn('TAG+="uaccess"', line) + self.assertIn("RC=0", r.stdout) + + def test_default_filename_sorts_below_seat_late(self): + # The rule file's default install path must sort before + # 73-seat-late.rules or the uaccess tag lands too late to be ACLed. + with open(ARCHSETUP) as f: + src = f.read() + func = re.search( + r'^install_camera_passthrough_rules\(\)\s*{.*?^}', src, re.S | re.M) + assert func is not None, "function not found in archsetup" + m = re.search(r'\$\{1:-(/etc/udev/rules\.d/[^}]+)\}', func.group(0)) + assert m is not None, "default rules path not found in the function" + basename = os.path.basename(m.group(1)) + self.assertLess(basename, "73-seat-late.rules", + "the rules file must sort below 73-seat-late.rules") + + # ---------------------------------------------------------- boundary ---- + def test_rerun_is_idempotent(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "72-test.rules") + first = run(path) + body_one = rules_body(first) + second = run(path) + self.assertEqual(body_one, rules_body(second)) + self.assertIn("RC=0", second.stdout) + + def test_overwrites_a_stale_existing_file(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "72-test.rules") + with open(path, "w") as f: + f.write("# stale content that must not survive\n") + r = run(path) + self.assertNotIn("stale content", rules_body(r)) + self.assertIn('GROUP="video"', rules_body(r)) + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_dir_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + locked = os.path.join(d, "locked") + os.makedirs(locked) + os.chmod(locked, stat.S_IRUSR | stat.S_IXUSR) + r = run(os.path.join(locked, "72-test.rules")) + os.chmod(locked, stat.S_IRWXU) + self.assertIn("WARN:", r.stdout) + self.assertIn("RC=", r.stdout, + "the harness must reach its RC line — the function " + "returned rather than aborting the script") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_install_cpu_microcode.py b/tests/installer-steps/test_install_cpu_microcode.py new file mode 100644 index 0000000..20f1820 --- /dev/null +++ b/tests/installer-steps/test_install_cpu_microcode.py @@ -0,0 +1,110 @@ +"""Test install_cpu_microcode — vendor-detected microcode package install. + +archsetup never installed intel-ucode/amd-ucode (found on velox 2026-04-10: +CPU running old microcode, hand-fixed). The step reads vendor_id from +/proc/cpuinfo and installs the matching package. It must run before +configure_grub in boot_ux: grub-mkconfig detects /boot/<vendor>-ucode.img for +its initrd lines, and mkinitcpio's microcode hook embeds it, so the package +has to exist before either generates. + +Method: sed-extract install_cpu_microcode from the real `archsetup`, point it +at a fixture cpuinfo, fake pacman_install / display / error_warn. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_cpu_microcode +""" + +import os +import re +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") + +INTEL = "vendor_id\t: GenuineIntel\n" +AMD = "vendor_id\t: AuthenticAMD\n" + + +def run(cpuinfo_body, missing=False): + with tempfile.TemporaryDirectory() as d: + cpuinfo = os.path.join(d, "cpuinfo") + if not missing: + with open(cpuinfo, "w") as f: + f.write(cpuinfo_body) + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + pacman_install() {{ echo "INSTALL: $1"; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^install_cpu_microcode() {{/,/^}}/p' "{ARCHSETUP}") + install_cpu_microcode "{cpuinfo}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class InstallCpuMicrocode(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_intel_vendor_installs_intel_ucode(self): + r = run(f"processor\t: 0\n{INTEL}model name\t: whatever\n") + self.assertIn("INSTALL: intel-ucode", r.stdout) + self.assertNotIn("amd-ucode", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_amd_vendor_installs_amd_ucode(self): + r = run(f"processor\t: 0\n{AMD}") + self.assertIn("INSTALL: amd-ucode", r.stdout) + self.assertNotIn("intel-ucode", r.stdout) + self.assertEqual(rc_of(r), 0) + + # ---------------------------------------------------------- boundary ---- + def test_multicore_cpuinfo_installs_exactly_once(self): + # /proc/cpuinfo repeats vendor_id per logical CPU. + body = "".join(f"processor\t: {i}\n{AMD}\n" for i in range(16)) + r = run(body) + self.assertEqual(r.stdout.count("INSTALL:"), 1, + "one package install, not one per core") + + def test_space_separated_vendor_line_parses(self): + # Some kernels/arches pad with spaces rather than a tab. + r = run("vendor_id : GenuineIntel\n") + self.assertIn("INSTALL: intel-ucode", r.stdout) + + def test_vendor_id_substring_elsewhere_does_not_confuse(self): + # A flags line or model name mentioning a vendor string must not win + # over the real vendor_id line. + body = "model name\t: AuthenticAMD emulator\n" + INTEL + r = run(body) + self.assertIn("INSTALL: intel-ucode", r.stdout) + self.assertNotIn("amd-ucode", r.stdout) + + # ------------------------------------------------------------- error ---- + def test_unknown_vendor_warns_and_installs_nothing(self): + r = run("vendor_id\t: CentaurHauls\n") + self.assertNotIn("INSTALL:", r.stdout) + self.assertIn("WARN:", r.stdout) + self.assertEqual(rc_of(r), 1) + + def test_missing_cpuinfo_warns_and_installs_nothing(self): + r = run("", missing=True) + self.assertNotIn("INSTALL:", r.stdout) + self.assertIn("WARN:", r.stdout) + self.assertEqual(rc_of(r), 1) + self.assertEqual(r.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_install_gnome_keyring_fix.py b/tests/installer-steps/test_install_gnome_keyring_fix.py new file mode 100644 index 0000000..1a4115a --- /dev/null +++ b/tests/installer-steps/test_install_gnome_keyring_fix.py @@ -0,0 +1,209 @@ +"""Tests for install_gnome_keyring_fix in the archsetup installer. + +gnome-keyring 51.1 (and 48 through 50) aborts when a Secret Service client's +first OpenSession is dispatched before the daemon has created its per-client +record. Every abort loses the unlocked state, so the next request pops the +keyring password dialog; on ratio this fired three times a day once the +ChatGPT desktop app's codex app-server started bursting keyring reads. The +fix is upstream merge request !112 plus an alias-lifetime correction, carried +as a patched rebuild of Arch's own package under packages/gnome-keyring in +this repo (pkgrel 1.1). install_gnome_keyring_fix builds and installs it with +makepkg as the user when the stock 1:51.1-1 is what is installed. + +Versioning is the whole contract. The rebuild sorts above stock 1:51.1-1 and +below any future 1:51.1-2 or 51.2, so a normal pacman upgrade replaces it the +moment Arch or upstream ships a fix, and the step must never pin or hold the +package. When something newer than the rebuild is already installed, the fix +is assumed upstream and the step skips with a warning so the assumption is +visible rather than silent. When the rebuild itself is installed, the step is +a no-op. + +These tests exercise the REAL function body, extracted from the `archsetup` +script at run time. pacman, vercmp, makepkg and sudo are replaced by +recorders driven by environment switches; the package directory is a temp +copy of the real packages/gnome-keyring tree. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_gnome_keyring_fix +""" + +import os +import re +import shutil +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") +PACKAGE_DIR = os.path.join(REPO_ROOT, "packages", "gnome-keyring") + +STUBS = r""" +logfile=/dev/null +action="" +username=tester +display() { echo "DISPLAY: $*"; } +pacman_install() { echo "PACMAN_INSTALL: $1" >> "$MARKER"; } +error_warn() { echo "WARN:$1"; return 1; } +# pacman -Q prints the installed version, or fails when INSTALLED is empty. +pacman() { + echo "PACMAN: $*" >> "$MARKER" + case "$1" in + -Q) [ -n "$INSTALLED" ] && echo "gnome-keyring $INSTALLED" ;; + *) return 0 ;; + esac +} +# The real vercmp is used so the ordering under test is pacman's own. +makepkg() { + echo "MAKEPKG: $* (pwd=$PWD)" >> "$MARKER" + [ "$MAKEPKG_FAILS" = 1 ] && return 2 + return 0 +} +# sudo -u user cmd... runs cmd in place; the recorder notes the user. +sudo() { + echo "SUDO: $*" >> "$MARKER" + shift 2 + "$@" +} +safe_rm_rf() { rm -rf "$1"; } +# chown needs root; the tests only care that the copy was staged. +chown() { echo "CHOWN: $*" >> "$MARKER"; } +""" + + +def run(pkg_dir, installed, makepkg_fails=False, source_dir=None): + marker = tempfile.NamedTemporaryFile(prefix="keyring-fix-", delete=False) + marker.close() + source_dir = source_dir or tempfile.mkdtemp(prefix="keyring-src-") + script = ( + STUBS + + "source <(sed -n '/^install_gnome_keyring_fix() {/,/^}/p' \"%s\")\n" + + 'user_archsetup_dir="%s"\n' + + 'source_dir="%s"\n' + + "install_gnome_keyring_fix\n" + + 'echo "RC=$?"\n' + ) % (ARCHSETUP, pkg_dir, source_dir) + env = dict(os.environ, MARKER=marker.name, INSTALLED=installed, + MAKEPKG_FAILS="1" if makepkg_fails else "0") + result = subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=30, env=env) + with open(marker.name) as f: + calls = f.read().splitlines() + os.remove(marker.name) + return result, calls, source_dir + + +class InstallGnomeKeyringFix(unittest.TestCase): + def setUp(self): + # A fake archsetup checkout holding a copy of the real package tree. + self.checkout = tempfile.mkdtemp(prefix="archsetup-checkout-") + self.addCleanup(shutil.rmtree, self.checkout, ignore_errors=True) + shutil.copytree(PACKAGE_DIR, os.path.join(self.checkout, "packages", "gnome-keyring")) + self.source_dir = tempfile.mkdtemp(prefix="keyring-src-") + self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True) + + def _run(self, installed, **kw): + return run(self.checkout, installed, source_dir=self.source_dir, **kw) + + @staticmethod + def _makepkg_calls(calls): + return [c for c in calls if c.startswith("MAKEPKG:")] + + # ---------------------------------------------------------- normal ---- + def test_stock_51_1_gets_the_rebuild(self): + result, calls, _ = self._run("1:51.1-1") + self.assertEqual(len(self._makepkg_calls(calls)), 1, calls) + self.assertIn("RC=0", result.stdout) + self.assertNotIn("WARN:", result.stdout) + + def test_builds_as_the_user_with_syncdeps_and_install(self): + _, calls, _ = self._run("1:51.1-1") + self.assertTrue(any(c.startswith("SUDO: -u tester makepkg") for c in calls), calls) + makepkg = self._makepkg_calls(calls)[0] + self.assertIn("--noconfirm", makepkg) + # -s (sync makedepends) and -i (install), spelled separately or combined. + short = "".join(t[1:] for t in makepkg.split() if re.fullmatch(r"-[a-zA-Z]+", t)) + self.assertIn("s", short, makepkg) + self.assertIn("i", short, makepkg) + + def test_builds_from_a_user_owned_copy_not_the_checkout(self): + # makepkg writes into its directory; the checkout must stay clean. + _, calls, _ = self._run("1:51.1-1") + makepkg = self._makepkg_calls(calls)[0] + self.assertIn("pwd=" + self.source_dir, makepkg) + self.assertNotIn("pwd=" + self.checkout, makepkg) + self.assertTrue(os.path.exists(os.path.join(self.source_dir, "gnome-keyring", "PKGBUILD"))) + + def test_older_stock_releases_also_get_the_rebuild(self): + # 50.0 carries the same bug; the rebuild is 51.1 so it is an upgrade. + _, calls, _ = self._run("1:50.0-1") + self.assertEqual(len(self._makepkg_calls(calls)), 1) + + # -------------------------------------------------------- boundary ---- + def test_rebuild_already_installed_is_a_noop(self): + result, calls, _ = self._run("1:51.1-1.1") + self.assertEqual(self._makepkg_calls(calls), []) + self.assertIn("RC=0", result.stdout) + self.assertNotIn("WARN:", result.stdout) + + def test_newer_package_skips_visibly_without_counting_an_error(self): + for newer in ("1:51.1-2", "1:51.2-1", "1:52.0-1"): + with self.subTest(installed=newer): + result, calls, _ = self._run(newer) + self.assertEqual(self._makepkg_calls(calls), [], newer) + self.assertIn("newer than the rebuild", result.stdout, + "a newer package means the fix is assumed upstream; say so") + self.assertNotIn("WARN:", result.stdout, + "a deliberate skip must not count as an installer error") + self.assertIn("RC=0", result.stdout) + + def test_rerun_after_success_does_not_rebuild(self): + _, calls, _ = self._run("1:51.1-1") + self.assertEqual(len(self._makepkg_calls(calls)), 1) + _, calls, _ = self._run("1:51.1-1.1") + self.assertEqual(self._makepkg_calls(calls), []) + + def test_missing_package_installs_stock_first_then_builds(self): + # With no gnome-keyring present, stock is installed as the baseline + # and the rebuild then upgrades it; both happen in that order. + result, calls, _ = self._run("") + self.assertEqual(len(self._makepkg_calls(calls)), 1) + stock = [i for i, c in enumerate(calls) if c.startswith("PACMAN_INSTALL: gnome-keyring")] + build = [i for i, c in enumerate(calls) if c.startswith("MAKEPKG:")] + self.assertEqual(len(stock), 1, calls) + self.assertLess(stock[0], build[0], "stock install must precede the build") + self.assertIn("RC=0", result.stdout) + + def test_rerun_with_rebuild_installed_never_touches_stock(self): + # pacman's --needed only skips an equal version, so a stock install + # ahead of the rebuild would downgrade it on every re-run. The step + # must not call the stock install at all once anything is installed. + for installed in ("1:51.1-1.1", "1:51.1-1", "1:51.2-1"): + with self.subTest(installed=installed): + _, calls, _ = self._run(installed) + self.assertFalse(any(c.startswith("PACMAN_INSTALL:") for c in calls), calls) + + # ----------------------------------------------------------- error ---- + def test_failed_build_warns_and_leaves_stock_in_place(self): + result, calls, _ = self._run("1:51.1-1", makepkg_fails=True) + self.assertIn("WARN:", result.stdout) + self.assertNotIn("RC=0", result.stdout) + self.assertFalse(any(c.startswith("PACMAN: -U") for c in calls), + "nothing may be installed by hand after a failed build") + + def test_missing_recipe_warns_instead_of_building(self): + shutil.rmtree(os.path.join(self.checkout, "packages", "gnome-keyring")) + result, calls, _ = self._run("1:51.1-1") + self.assertEqual(self._makepkg_calls(calls), []) + self.assertIn("WARN:", result.stdout) + + def test_never_pins_or_holds_the_package(self): + # No IgnorePkg edit and no pacman -D hold: the next upstream fix must + # replace the rebuild on a normal upgrade. + _, calls, _ = self._run("1:51.1-1") + self.assertFalse(any("IgnorePkg" in c for c in calls)) + self.assertFalse(any(c.startswith("PACMAN: -D") for c in calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_mask_fwupd_passim.py b/tests/installer-steps/test_mask_fwupd_passim.py new file mode 100644 index 0000000..8977504 --- /dev/null +++ b/tests/installer-steps/test_mask_fwupd_passim.py @@ -0,0 +1,79 @@ +"""Test mask_fwupd_passim — keep fwupd's LAN metadata daemon off. + +fwupd pulls in passim, a daemon that shares firmware metadata with other +machines on the LAN by listening publicly on 0.0.0.0:27500. Any fwupdmgr +run D-Bus-activates it, and because the unit is static (no [Install] +section) `systemctl disable` is a no-op: it came back on velox the next +time fwupdmgr ran (2026-09-12). Masking is what holds, and it is how +ratio has carried it since 2026-07-21. + +Method: sed-extract mask_fwupd_passim from the real `archsetup`; fake +run_task (minus error_warn, so the failure test sees the function's own +return path) and systemctl, and assert on the call. + + python3 -m unittest tests.installer-steps.test_mask_fwupd_passim +""" + +import os +import re +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(systemctl_body='echo "SYSTEMCTL: $*";'): + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + run_task() {{ echo "TASK: $1"; shift; "$@"; }} + systemctl() {{ {systemctl_body} }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^mask_fwupd_passim() {{/,/^}}/p' "{ARCHSETUP}") + mask_fwupd_passim + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class MaskFwupdPassim(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_masks_the_passim_unit(self): + r = run() + self.assertIn("SYSTEMCTL: mask passim.service", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_masks_rather_than_disables(self): + # disable is a no-op on a static unit and is the mistake this step + # exists to avoid; a mask is the only call that sticks. + r = run() + calls = [l for l in r.stdout.splitlines() if l.startswith("SYSTEMCTL:")] + self.assertEqual(calls, ["SYSTEMCTL: mask passim.service"]) + + # ---------------------------------------------------------- boundary ---- + # Re-running is idempotent because `systemctl mask` on an already-masked + # unit exits 0 and changes nothing; that property lives in systemctl, so + # there is no stateless test here that could tell it apart from a pass. + + # ------------------------------------------------------------- error ---- + def test_failed_mask_reports_nonzero(self): + # The fake run_task returns the command's status without the real + # error_warn wiring, so this checks the function's own return path. + r = run(systemctl_body='echo "SYSTEMCTL: $*"; return 1;') + self.assertNotEqual(rc_of(r), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_mask_tmp_mount_for_zfs.py b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py new file mode 100644 index 0000000..f03086f --- /dev/null +++ b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py @@ -0,0 +1,109 @@ +"""Test mask_tmp_mount_for_zfs — the tmpfs-over-ZFS /tmp guard. + +When the pool carries a /tmp dataset, systemd's tmp.mount (tmpfs) races it at +boot; when tmpfs wins, the dataset is shadowed and systemd-tmpfiles-clean +fails repeatedly with "Protocol driver not attached" (velox, 2026-04-10). The +fix is masking tmp.mount so the dataset owns /tmp — but only when such a +dataset actually exists, and never on a machine without zfs at all. + +Method: sed-extract mask_tmp_mount_for_zfs from the real `archsetup`; fake +zfs / run_task / systemctl. The zfs-binary-absent case runs with a stripped +PATH containing only the tools the function itself needs. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_mask_tmp_mount_for_zfs +""" + +import os +import re +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(zfs_body=None, strip_zfs_from_path=False): + """zfs_body: bash body for a fake zfs function, or None for no fake. + strip_zfs_from_path: run with a minimal PATH that has grep/sed but no zfs. + """ + with tempfile.TemporaryDirectory() as d: + path_setup = "" + if strip_zfs_from_path: + fakebin = os.path.join(d, "bin") + os.makedirs(fakebin) + for tool in ("grep", "sed", "cat"): + src = subprocess.run(["bash", "-lc", f"command -v {tool}"], + capture_output=True, text=True).stdout.strip() + if src: + os.symlink(src, os.path.join(fakebin, tool)) + path_setup = f'PATH="{fakebin}"' + zfs_fake = f"zfs() {{ {zfs_body} }}" if zfs_body is not None else "" + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + run_task() {{ echo "TASK: $1"; shift; "$@"; }} + systemctl() {{ echo "SYSTEMCTL: $*"; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + {zfs_fake} + source <(sed -n '/^mask_tmp_mount_for_zfs() {{/,/^}}/p' "{ARCHSETUP}") + {path_setup} + mask_tmp_mount_for_zfs + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class MaskTmpMountForZfs(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_tmp_dataset_present_masks_tmp_mount(self): + r = run(zfs_body='printf "/\\n/home\\n/tmp\\n";') + self.assertIn("SYSTEMCTL: mask tmp.mount", r.stdout) + self.assertEqual(rc_of(r), 0) + + # ---------------------------------------------------------- boundary ---- + def test_no_tmp_dataset_is_a_no_op(self): + r = run(zfs_body='printf "/\\n/home\\n/var\\n";') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_tmp_prefix_dataset_does_not_match(self): + # /tmp/scratch or /tmpfoo must not trigger the mask — exact match only. + r = run(zfs_body='printf "/\\n/tmp/scratch\\n/tmpfoo\\n";') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_legacy_and_none_mountpoints_do_not_match(self): + r = run(zfs_body='printf "legacy\\nnone\\n-\\n";') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + + # ------------------------------------------------------------- error ---- + def test_zfs_binary_absent_is_a_silent_no_op(self): + r = run(zfs_body=None, strip_zfs_from_path=True) + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertNotIn("WARN:", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_zfs_list_failure_is_a_no_op_not_a_crash(self): + r = run(zfs_body='echo "no pools available" >&2; return 1;') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + self.assertNotIn("no pools available", r.stdout, + "zfs stderr noise must not leak into output") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py index a2b235b..c2eb710 100644 --- a/tests/installer-steps/test_orchestrators.py +++ b/tests/installer-steps/test_orchestrators.py @@ -28,6 +28,7 @@ ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") ORCHESTRATORS = { "essential_services": [ "configure_randomness", "configure_networking", "configure_power", + "configure_hibernate_delay", "configure_backlight_access", "configure_ssh_server", "configure_fail2ban", "configure_firewall", "configure_service_discovery", "configure_job_scheduling", "configure_package_cache", "configure_snapshots", @@ -43,10 +44,10 @@ ORCHESTRATORS = { "install_devops_utilities", ], "boot_ux": [ - "tighten_efi_permissions", "add_nvme_early_module", - "configure_initramfs_hook", "configure_encrypted_autologin", - "configure_tlp_power", "trim_firmware", "configure_grub", - "configure_pre_pacman_snapshots", + "install_cpu_microcode", "tighten_efi_permissions", + "add_nvme_early_module", "configure_initramfs_hook", + "configure_encrypted_autologin", "configure_tlp_power", + "trim_firmware", "configure_grub", "configure_pre_pacman_snapshots", ], "user_customizations": [ "clone_user_repos", "stow_dotfiles", "prune_waybar_battery", @@ -88,7 +89,8 @@ class OrchestratorSequence(unittest.TestCase): class SnapshotDispatch(unittest.TestCase): """configure_snapshots branches on filesystem; pin each branch.""" - SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots"] + SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots", + "mask_tmp_mount_for_zfs"] def test_zfs_root_runs_zfs_snapshots(self): result = run_orchestrator( @@ -96,7 +98,8 @@ class SnapshotDispatch(unittest.TestCase): extra_defs="is_zfs_root() { return 0; }\nis_btrfs_root() { return 1; }", ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.split(), ["configure_zfs_snapshots"]) + self.assertEqual(result.stdout.split(), + ["configure_zfs_snapshots", "mask_tmp_mount_for_zfs"]) def test_btrfs_root_runs_btrfs_snapshots(self): result = run_orchestrator( |
