diff options
Diffstat (limited to 'tests/installer-steps')
3 files changed, 311 insertions, 0 deletions
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_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_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() |
