aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/bluetooth-resume/test_bluetooth_resume.py139
-rw-r--r--tests/installer-steps/test_configure_service_discovery.py85
-rw-r--r--tests/installer-steps/test_configure_tlp_power.py50
-rw-r--r--tests/installer-steps/test_configure_tunnel_dns_over_tls.py147
-rw-r--r--tests/installer-steps/test_dotfiles_dependency_packages.py119
-rw-r--r--tests/installer-steps/test_mask_fwupd_passim.py79
-rw-r--r--tests/post-rebuild-check/test_post_rebuild_check.py2
7 files changed, 619 insertions, 2 deletions
diff --git a/tests/bluetooth-resume/test_bluetooth_resume.py b/tests/bluetooth-resume/test_bluetooth_resume.py
new file mode 100644
index 0000000..6d8ed87
--- /dev/null
+++ b/tests/bluetooth-resume/test_bluetooth_resume.py
@@ -0,0 +1,139 @@
+"""Tests for scripts/zz-bluetooth-resume.
+
+Two things break bluetooth across a sleep cycle on a TLP laptop, and nothing
+else on the machine fixes either.
+
+The rfkill soft-block is not restored. systemd-rfkill would do it, but it is
+masked deliberately -- it fights TLP's radio handling, so TLP owns radios
+instead. TLP's own sleep hook runs `tlp resume`, and its setting is
+DEVICES_TO_ENABLE_ON_STARTUP: startup, not resume. There is no ON_RESUME in
+TLP's vocabulary, so the resume edge has no owner at all. WiFi survives only
+because NetworkManager unblocks itself; bluetooth has no equivalent.
+
+The controller also comes back wedged from a hibernate. It reports powered and
+unblocked while scanning finds nothing whatever -- zero devices where the same
+room gave seventeen a minute later. bluetoothd logs "Failed to set mode" and
+"Failed to add device <mac>" at the instant of resume. Reloading btusb clears
+it.
+
+Both observed on velox 2026-08-21, on its first suspend-then-hibernate cycle
+after hibernate was switched back on.
+
+The hook re-asserts TLP's own declared intent rather than inventing a policy,
+so a machine that deliberately blocks bluetooth keeps it blocked.
+
+Run from repo root:
+ python3 -m unittest tests.bluetooth-resume.test_bluetooth_resume
+"""
+
+import os
+import stat
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+HOOK = os.path.join(REPO_ROOT, "scripts", "zz-bluetooth-resume")
+
+TLP_WANTS_BT = 'DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"\n'
+TLP_WIFI_ONLY = 'DEVICES_TO_ENABLE_ON_STARTUP="wifi"\n'
+
+
+def run(phase="post", kind="suspend-then-hibernate", tlp_conf=TLP_WANTS_BT,
+ conf_present=True):
+ """Drive the hook with rfkill and modprobe faked, and read back the calls."""
+ with tempfile.TemporaryDirectory() as d:
+ calls = os.path.join(d, "calls.log")
+ bindir = os.path.join(d, "bin")
+ os.makedirs(bindir)
+ for tool in ("rfkill", "modprobe"):
+ p = os.path.join(bindir, tool)
+ with open(p, "w") as fh:
+ fh.write(f'#!/bin/sh\necho "{tool} $*" >> "{calls}"\nexit 0\n')
+ os.chmod(p, 0o755)
+ conf = os.path.join(d, "tlp.conf")
+ if conf_present:
+ with open(conf, "w") as fh:
+ fh.write(tlp_conf)
+ env = dict(os.environ)
+ env.update({
+ "BTR_RFKILL": os.path.join(bindir, "rfkill"),
+ "BTR_MODPROBE": os.path.join(bindir, "modprobe"),
+ "BTR_TLP_CONF": conf,
+ "BTR_TLP_CONF_DIR": os.path.join(d, "tlp.d"),
+ "BTR_SETTLE": "0",
+ })
+ r = subprocess.run(["sh", HOOK, phase, kind], env=env,
+ capture_output=True, text=True, timeout=20)
+ log = ""
+ if os.path.exists(calls):
+ with open(calls) as fh:
+ log = fh.read()
+ return r, log
+
+
+class BluetoothResume(unittest.TestCase):
+ # --- Normal ---------------------------------------------------------
+ def test_hibernate_reloads_the_driver_and_unblocks(self):
+ _, log = run(kind="suspend-then-hibernate")
+ self.assertIn("modprobe -r btusb", log)
+ self.assertIn("modprobe btusb", log)
+ self.assertIn("rfkill unblock bluetooth", log)
+
+ def test_the_unblock_comes_after_the_reload(self):
+ # A freshly loaded btusb can come up soft-blocked, so unblocking first
+ # would be undone by the reload that follows it.
+ _, log = run()
+ self.assertLess(log.index("modprobe btusb"),
+ log.index("rfkill unblock"))
+
+ def test_plain_suspend_unblocks_without_reloading(self):
+ # The wedge was seen coming out of hibernate, which reinitialises the
+ # controller from a saved image. A plain suspend restores USB intact,
+ # so reloading there would cost a working adapter for nothing.
+ _, log = run(kind="suspend")
+ self.assertIn("rfkill unblock bluetooth", log)
+ self.assertNotIn("btusb", log)
+
+ # --- Boundary -------------------------------------------------------
+ def test_the_pre_phase_does_nothing(self):
+ _, log = run(phase="pre")
+ self.assertEqual(log, "")
+
+ def test_a_tlp_policy_without_bluetooth_is_left_alone(self):
+ # The hook re-asserts TLP's stated intent. It must not invent one, or
+ # a machine that deliberately keeps bluetooth off gets it turned on at
+ # every wakeup.
+ _, log = run(tlp_conf=TLP_WIFI_ONLY)
+ self.assertEqual(log, "")
+
+ def test_a_commented_out_policy_does_not_count(self):
+ _, log = run(tlp_conf='#DEVICES_TO_ENABLE_ON_STARTUP="bluetooth"\n')
+ self.assertEqual(log, "")
+
+ def test_hibernate_proper_also_reloads(self):
+ _, log = run(kind="hibernate")
+ self.assertIn("modprobe -r btusb", log)
+
+ # --- Error ----------------------------------------------------------
+ def test_a_missing_tlp_config_is_left_alone(self):
+ # No declared policy means no intent to re-assert. Failing safe here
+ # means doing nothing, not guessing.
+ _, log = run(conf_present=False)
+ self.assertEqual(log, "")
+
+ def test_the_hook_always_exits_zero(self):
+ # systemd-sleep logs a failing hook and the noise outlives the cause.
+ # Nothing here is worth delaying or alarming a resume over.
+ for kind in ("suspend", "hibernate", "suspend-then-hibernate"):
+ with self.subTest(kind=kind):
+ r, _ = run(kind=kind)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_it_is_executable(self):
+ self.assertTrue(os.stat(HOOK).st_mode & stat.S_IXUSR,
+ "systemd-sleep only runs executables")
+
+
+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
index 1ddff72..dc46c93 100644
--- a/tests/installer-steps/test_configure_tlp_power.py
+++ b/tests/installer-steps/test_configure_tlp_power.py
@@ -20,6 +20,15 @@ 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.
@@ -39,7 +48,8 @@ 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):
+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)
@@ -52,6 +62,9 @@ def run(battery=True, bat_name="BAT0", unwritable_tlpd=False, systemctl_fails=Fa
# 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=""
@@ -60,6 +73,8 @@ def run(battery=True, bat_name="BAT0", unwritable_tlpd=False, systemctl_fails=Fa
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=$?"
@@ -138,7 +153,29 @@ class ConfigureTlpPower(unittest.TestCase):
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)
@@ -179,6 +216,17 @@ class ConfigureTlpPower(unittest.TestCase):
"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)
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_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/post-rebuild-check/test_post_rebuild_check.py b/tests/post-rebuild-check/test_post_rebuild_check.py
index bc887c6..1145f34 100644
--- a/tests/post-rebuild-check/test_post_rebuild_check.py
+++ b/tests/post-rebuild-check/test_post_rebuild_check.py
@@ -888,7 +888,7 @@ class SignalAccount(unittest.TestCase):
# --- Normal cases ---------------------------------------------------
def test_registered_account_passes(self):
- r = run_check(signal_accounts="Number: +15045173983 ...")
+ r = run_check(signal_accounts="Number: +15045551234 ...")
self.assertEqual(r.returncode, 0, r.stdout)
def test_no_account_flags(self):