aboutsummaryrefslogtreecommitdiff
path: root/scripts/testing/tests
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/testing/tests')
-rw-r--r--scripts/testing/tests/conftest.py111
-rw-r--r--scripts/testing/tests/test_archsetup.py26
-rw-r--r--scripts/testing/tests/test_backups.py44
-rw-r--r--scripts/testing/tests/test_boot.py67
-rw-r--r--scripts/testing/tests/test_config_applied.py55
-rw-r--r--scripts/testing/tests/test_desktop.py111
-rw-r--r--scripts/testing/tests/test_dotfiles.py19
-rw-r--r--scripts/testing/tests/test_hardening.py50
-rw-r--r--scripts/testing/tests/test_keyring.py35
-rw-r--r--scripts/testing/tests/test_packages.py60
-rw-r--r--scripts/testing/tests/test_services.py103
-rw-r--r--scripts/testing/tests/test_users.py34
12 files changed, 715 insertions, 0 deletions
diff --git a/scripts/testing/tests/conftest.py b/scripts/testing/tests/conftest.py
new file mode 100644
index 0000000..680c967
--- /dev/null
+++ b/scripts/testing/tests/conftest.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Pytest + Testinfra config for archsetup post-install validation.
+
+These tests run on the *host* and connect to the freshly-installed VM over SSH
+(Testinfra provides the `host` fixture, parametrized from --hosts). This file
+adds two things the bespoke shell harness had that Testinfra does not:
+
+ - Failure attribution. Each check is marked with the layer that owns a
+ failure (archsetup | base_install | unknown), mirroring validation.sh's
+ attribute_issue. Failures are bucketed and written to --attribution-file
+ so run-test.sh can route base-install issues to the archzfs inbox as before.
+ - Tiering markers (smoke | integration) so `pytest -m smoke` is a fast gate.
+
+The `target_user` fixture supplies the account archsetup created; it reads
+ARCHSETUP_TEST_USER (set by run-test.sh from the VM conf) and defaults to the
+historical "cjennings".
+"""
+
+import os
+
+import pytest
+
+
+_ATTRIBUTION_BUCKETS = ("archsetup", "base_install", "unknown")
+_failures = {bucket: [] for bucket in _ATTRIBUTION_BUCKETS}
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--attribution-file",
+ action="store",
+ default=None,
+ help="write the failure attribution report (archsetup/base_install/unknown) here",
+ )
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "attribution(bucket): layer that owns a failure — archsetup, base_install, or unknown",
+ )
+ config.addinivalue_line("markers", "smoke: fast subset (user, key packages, dotfiles present)")
+ config.addinivalue_line("markers", "integration: full post-install checks")
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_makereport(item, call):
+ report = yield
+ if report.when == "call" and report.failed:
+ marker = item.get_closest_marker("attribution")
+ bucket = marker.args[0] if (marker and marker.args) else "archsetup"
+ if bucket not in _failures:
+ bucket = "unknown"
+ _failures[bucket].append(item.nodeid)
+ return report
+
+
+def pytest_sessionfinish(session, exitstatus):
+ path = session.config.getoption("--attribution-file")
+ if not path:
+ return
+ with open(path, "w") as fh:
+ for bucket in _ATTRIBUTION_BUCKETS:
+ fh.write("[%s]\n" % bucket)
+ for nodeid in _failures[bucket]:
+ fh.write(" %s\n" % nodeid)
+
+
+@pytest.fixture(scope="session")
+def target_user():
+ """The account archsetup created in the VM under test."""
+ return os.environ.get("ARCHSETUP_TEST_USER", "cjennings")
+
+
+@pytest.fixture(scope="session")
+def home(target_user):
+ return "/home/%s" % target_user
+
+
+@pytest.fixture(scope="module")
+def zfs_root(host):
+ """True when the VM's root filesystem is ZFS (gates ZFS-specific checks)."""
+ return host.run("findmnt -n -o FSTYPE /").stdout.strip() == "zfs"
+
+
+@pytest.fixture(scope="module")
+def has_nvme(host):
+ """True when the VM exposes an NVMe device."""
+ return host.run("ls /dev/nvme0n1 2>/dev/null").rc == 0
+
+
+@pytest.fixture(scope="module")
+def hyprland_installed(host):
+ return host.package("hyprland").is_installed
+
+
+@pytest.fixture(scope="module")
+def dwm_installed(host):
+ return host.file("/usr/local/bin/dwm").exists
+
+
+@pytest.fixture(scope="module")
+def compositor_running(host):
+ """A graphical session is live (gates socket/portal checks that need one)."""
+ return host.run("pgrep -x Hyprland").rc == 0
+
+
+@pytest.fixture(scope="module")
+def on_slirp(host):
+ """QEMU user-mode networking (10.0.2.x) — no multicast, so mDNS can't work."""
+ return "10.0.2." in host.run("ip -4 addr show").stdout
diff --git a/scripts/testing/tests/test_archsetup.py b/scripts/testing/tests/test_archsetup.py
new file mode 100644
index 0000000..52fe3f7
--- /dev/null
+++ b/scripts/testing/tests/test_archsetup.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: archsetup's own log and state markers.
+
+Parity port of validate_archsetup_log and validate_state_markers.
+"""
+
+import pytest
+
+
+EXPECTED_STATE_STEPS = 12
+
+
+@pytest.mark.attribution("archsetup")
+def test_no_errors_in_archsetup_log(host):
+ out = host.run("grep -h '^Error:' /var/log/archsetup-*.log 2>/dev/null | wc -l")
+ count = int((out.stdout.strip() or "0"))
+ assert count == 0, "archsetup log reported %d Error: lines" % count
+
+
+@pytest.mark.attribution("archsetup")
+def test_all_install_steps_completed(host):
+ out = host.run("ls /var/lib/archsetup/state/ 2>/dev/null | wc -l")
+ count = int((out.stdout.strip() or "0"))
+ assert count >= EXPECTED_STATE_STEPS, (
+ "only %d/%d install steps completed" % (count, EXPECTED_STATE_STEPS)
+ )
diff --git a/scripts/testing/tests/test_backups.py b/scripts/testing/tests/test_backups.py
new file mode 100644
index 0000000..07da5ec
--- /dev/null
+++ b/scripts/testing/tests/test_backups.py
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: backup_system_file ran during a real install.
+
+Expansion coverage (P4). The unit suite (tests/backup-system-file/) covers the
+helper's logic; this confirms it actually fires end-to-end — archsetup leaves a
+<file>.archsetup.bak next to each pre-existing file it edits in place.
+
+These targets are edited unconditionally on every run (pacman.conf/makepkg.conf
+always sed'd, sudoers always appended), so their backups must exist.
+mkinitcpio.conf is edited only conditionally (the systemd-hook switch on
+non-ZFS, or the nvme module on NVMe systems), so it gets its own fixture-gated
+check below. Conditionally-edited files (locale.gen, geoclue, fstab) aren't
+asserted here since their edits depend on the base image.
+"""
+
+import pytest
+
+
+ALWAYS_BACKED_UP = [
+ "/etc/pacman.conf",
+ "/etc/makepkg.conf",
+ "/etc/sudoers",
+]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("path", ALWAYS_BACKED_UP)
+def test_backup_created_for_edited_file(host, path):
+ bak = host.file(path + ".archsetup.bak")
+ assert bak.exists, "%s.archsetup.bak missing — backup_system_file did not fire" % path
+ assert bak.is_file
+
+
+@pytest.mark.attribution("archsetup")
+def test_backup_created_for_mkinitcpio(host, zfs_root, has_nvme):
+ # archsetup edits /etc/mkinitcpio.conf only when it has something to change:
+ # the systemd-hook switch (non-ZFS only) or adding the nvme module (NVMe
+ # systems). A ZFS root with no NVMe touches neither, so there's no backup.
+ if zfs_root and not has_nvme:
+ pytest.skip("ZFS root + no NVMe: archsetup doesn't edit mkinitcpio.conf")
+ bak = host.file("/etc/mkinitcpio.conf.archsetup.bak")
+ assert bak.exists, \
+ "/etc/mkinitcpio.conf.archsetup.bak missing — backup_system_file did not fire"
+ assert bak.is_file
diff --git a/scripts/testing/tests/test_boot.py b/scripts/testing/tests/test_boot.py
new file mode 100644
index 0000000..78b4404
--- /dev/null
+++ b/scripts/testing/tests/test_boot.py
@@ -0,0 +1,67 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: boot, initramfs, and filesystem config.
+
+Parity port of validate_zfs_config, validate_boot_config,
+validate_mkinitcpio_hooks, validate_initramfs_consolefont, validate_nvme_module.
+Filesystem/hardware-specific checks are gated on fixtures.
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_bootloader_installed(host, zfs_root):
+ # A ZFS root boots via ZFSBootMenu (archangel installs the EFI binary under
+ # /efi/EFI/ZBM), so there is no GRUB; a non-ZFS root uses GRUB.
+ if zfs_root:
+ assert host.file("/efi/EFI/ZBM/zfsbootmenu.efi").exists, \
+ "ZFS root must have the ZFSBootMenu EFI binary"
+ else:
+ assert host.file("/boot/grub/grub.cfg").exists, \
+ "non-ZFS root must have a GRUB config"
+
+
+@pytest.mark.attribution("archsetup")
+def test_mkinitcpio_hooks(host, zfs_root):
+ hooks = host.run("grep '^HOOKS=' /etc/mkinitcpio.conf").stdout
+ if zfs_root:
+ # ZFS must use the udev hook; the systemd hook breaks a ZFS boot.
+ assert " udev" in hooks or "(udev" in hooks, "ZFS root must use the udev hook"
+ assert "systemd" not in hooks, "ZFS root must not use the systemd hook"
+ else:
+ # Non-ZFS: either hook is acceptable.
+ assert ("systemd" in hooks) or ("udev" in hooks)
+
+
+@pytest.mark.attribution("archsetup")
+def test_console_font_configured(host, zfs_root):
+ # archsetup sets FONT=ter-132n in /etc/vconsole.conf on every run.
+ assert host.file("/etc/vconsole.conf").contains("^FONT=ter-132n"), \
+ "archsetup should set FONT=ter-132n in /etc/vconsole.conf"
+ # On non-ZFS it also rebuilds the initramfs (mkinitcpio -P) so the font is
+ # baked in for early boot. On ZFS that rebuild is skipped (the busybox ZFS
+ # hook is incompatible with the systemd-hook switch), so the font applies at
+ # the vconsole layer once userspace starts, not inside the initramfs.
+ if zfs_root:
+ return
+ # Pick the main initramfs (this fleet runs linux-lts, so the name is
+ # initramfs-linux-lts.img, not initramfs-linux.img); skip the fallback image.
+ img = host.run(
+ "ls /boot/initramfs-*.img 2>/dev/null | grep -v fallback | head -1"
+ ).stdout.strip()
+ assert img, "no initramfs image found under /boot"
+ out = host.run("lsinitcpio %s 2>/dev/null | grep -cE 'consolefont.psf|ter-'" % img)
+ assert int((out.stdout.strip() or "0")) > 0, "console font not found in %s" % img
+
+
+def test_nvme_module_when_nvme_present(host, has_nvme):
+ if not has_nvme:
+ pytest.skip("no NVMe device present")
+ modules = host.run("grep '^MODULES=' /etc/mkinitcpio.conf").stdout
+ assert "nvme" in modules, "NVMe system should list nvme in mkinitcpio MODULES"
+
+
+def test_zfs_has_sanoid(host):
+ if not host.exists("zfs"):
+ pytest.skip("ZFS not installed (non-ZFS system)")
+ assert host.exists("sanoid"), "ZFS system should have sanoid installed"
diff --git a/scripts/testing/tests/test_config_applied.py b/scripts/testing/tests/test_config_applied.py
new file mode 100644
index 0000000..00c410e
--- /dev/null
+++ b/scripts/testing/tests/test_config_applied.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: archsetup's in-place config edits actually took effect.
+
+Expansion coverage (P4). These assert the *content* archsetup writes, not just
+that a service is enabled — catching cases where a sed silently no-ops (e.g.
+ParallelDownloads, which current Arch ships uncommented so a "^#"-only match
+left it at 5).
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_parallel_downloads(host):
+ line = host.run("grep -E '^ParallelDownloads' /etc/pacman.conf").stdout
+ assert "ParallelDownloads = 10" in line, "ParallelDownloads not set to 10 (got: %r)" % line
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_color_enabled(host):
+ assert host.run("grep -qx Color /etc/pacman.conf").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_multilib_enabled(host):
+ # -F: [multilib] is a literal section header, not a regex character class.
+ assert host.run("grep -Fxq '[multilib]' /etc/pacman.conf").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_makepkg_parallel_make(host):
+ line = host.run("grep -E '^MAKEFLAGS' /etc/makepkg.conf").stdout
+ assert "-j" in line, "MAKEFLAGS not configured for parallel make (got: %r)" % line
+
+
+@pytest.mark.attribution("archsetup")
+def test_makepkg_options_trimmed(host):
+ opts = host.run("grep -E '^OPTIONS' /etc/makepkg.conf").stdout
+ assert "!debug" in opts and "purge" in opts, "makepkg OPTIONS not customized"
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("rel", ["dns.conf", "wifi-privacy.conf"])
+def test_networkmanager_dropin(host, rel):
+ assert host.file("/etc/NetworkManager/conf.d/%s" % rel).exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_fail2ban_jail_local(host):
+ assert host.file("/etc/fail2ban/jail.local").exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_reflector_config(host):
+ assert host.file("/etc/xdg/reflector/reflector.conf").exists
diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py
new file mode 100644
index 0000000..c02d2b6
--- /dev/null
+++ b/scripts/testing/tests/test_desktop.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: window manager + desktop integration.
+
+Parity port of validate_window_manager and its Hyprland/DWM branches, plus
+validate_autologin_config. Hyprland and DWM checks are gated on which DE the
+run installed; socket/portal-query checks are gated on a live compositor (the
+headless test VM has none).
+
+Note: validate_hyprland_tools historically checked `swww`, but archsetup now
+installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so
+this checks awww. That divergence from the shell sweep is a correctness fix.
+"""
+
+import pytest
+
+
+HYPRLAND_TOOLS = [
+ "hyprland", "hypridle", "hyprlock", "waybar", "fuzzel",
+ "awww", "grim", "slurp", "gammastep", "foot",
+]
+
+HYPRLAND_CONFIGS = [
+ ".config/hypr/hyprland.conf",
+ ".config/hypr/hypridle.conf",
+ ".config/hypr/hyprlock.conf",
+ ".config/waybar/config",
+ ".config/fuzzel/fuzzel.ini",
+ ".config/gammastep/config.ini",
+]
+
+SUCKLESS_TOOLS = ["dwm", "st", "dmenu", "slock"]
+
+PORTALS_CONF = ".config/xdg-desktop-portal/portals.conf"
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", HYPRLAND_TOOLS)
+def test_hyprland_tool_installed(host, hyprland_installed, pkg):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ assert host.package(pkg).is_installed, "%s missing" % pkg
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("rel", HYPRLAND_CONFIGS)
+def test_hyprland_config_present(host, hyprland_installed, home, rel):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ assert host.file("%s/%s" % (home, rel)).exists, "%s missing" % rel
+
+
+@pytest.mark.attribution("archsetup")
+def test_live_update_guard_installed(host, hyprland_installed):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ guard = host.file("/usr/local/bin/hypr-live-update-guard")
+ assert guard.exists, "live-update guard script missing"
+ assert guard.mode & 0o111, "live-update guard not executable"
+ hook = host.file("/etc/pacman.d/hooks/hypr-live-update-guard.hook")
+ assert hook.exists, "live-update guard pacman hook missing"
+ assert "hypr-live-update-guard" in hook.content_string, \
+ "hook does not invoke the guard script"
+
+
+@pytest.mark.attribution("archsetup")
+def test_portal_settings_backend_not_disabled(host, hyprland_installed, home):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ conf = host.file("%s/%s" % (home, PORTALS_CONF))
+ assert conf.exists, "portals.conf missing"
+ line = host.run(
+ "grep org.freedesktop.impl.portal.Settings %s" % conf.path
+ ).stdout
+ assert "=none" not in line.replace(" ", ""), "Settings portal disabled (=none)"
+
+
+def test_portal_returns_dark_mode(host, hyprland_installed, compositor_running, target_user):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ if not compositor_running:
+ pytest.skip("no compositor running (headless) — portal query not applicable")
+ cmd = (
+ "sudo -u %s busctl --user call org.freedesktop.portal.Desktop "
+ "/org/freedesktop/portal/desktop org.freedesktop.portal.Settings Read "
+ "'ss' 'org.freedesktop.appearance' 'color-scheme'" % target_user
+ )
+ out = host.run(cmd).stdout
+ assert "u 1" in out, "Settings portal should report color-scheme=1 (dark)"
+
+
+def test_hyprland_socket(host, hyprland_installed, compositor_running):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ if not compositor_running:
+ pytest.skip("Hyprland not running (headless) — socket check not applicable")
+ assert host.run("test -S /tmp/hypr/*/.socket.sock").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("tool", SUCKLESS_TOOLS)
+def test_suckless_tool_installed(host, dwm_installed, tool):
+ if not dwm_installed:
+ pytest.skip("DWM not installed (DESKTOP_ENV != dwm)")
+ assert host.file("/usr/local/bin/%s" % tool).exists, "%s missing" % tool
+
+
+def test_autologin_configured(host):
+ conf = host.file("/etc/systemd/system/getty@tty1.service.d/autologin.conf")
+ if not conf.exists:
+ pytest.skip("autologin not configured (AUTOLOGIN=no, may be intentional)")
+ assert conf.exists
diff --git a/scripts/testing/tests/test_dotfiles.py b/scripts/testing/tests/test_dotfiles.py
new file mode 100644
index 0000000..cd6e474
--- /dev/null
+++ b/scripts/testing/tests/test_dotfiles.py
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: dotfiles stowed for the user.
+
+Parity port of validate_dotfiles from validation.sh: .zshrc must be a symlink
+into the ~/.dotfiles stow tree, not broken, and readable by the user (not just
+root).
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_zshrc_stowed_and_readable(host, target_user):
+ zshrc = host.file("/home/%s/.zshrc" % target_user)
+ assert zshrc.is_symlink, ".zshrc should be a stow symlink"
+ assert ".dotfiles/" in zshrc.linked_to, "symlink should point into ~/.dotfiles"
+ assert zshrc.exists, "symlink target must exist (not broken)"
+ # Readable by the user, not only root.
+ assert host.run("sudo -u %s test -r %s" % (target_user, zshrc.path)).rc == 0
diff --git a/scripts/testing/tests/test_hardening.py b/scripts/testing/tests/test_hardening.py
new file mode 100644
index 0000000..f12b0e6
--- /dev/null
+++ b/scripts/testing/tests/test_hardening.py
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: security/system hardening archsetup applies.
+
+Expansion coverage (P4) — these were not in the original shell sweep. They
+assert the system-level changes archsetup makes in place: sshd root hardening,
+quiet kernel console, an emptied /etc/issue, the console font, and the EFI
+mount permission tightening.
+"""
+
+import pytest
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_sshd_root_prohibit_password(host):
+ conf = host.file("/etc/ssh/sshd_config.d/10-hardening.conf")
+ assert conf.exists, "sshd hardening drop-in missing"
+ assert "PermitRootLogin prohibit-password" in conf.content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_quiet_printk_sysctl(host):
+ conf = host.file("/etc/sysctl.d/20-quiet-printk.conf")
+ assert conf.exists
+ assert "kernel.printk" in conf.content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_issue_emptied(host):
+ # archsetup truncates /etc/issue to drop the distro/date banner.
+ assert host.file("/etc/issue").size == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_console_font_configured(host):
+ assert "ter-132n" in host.file("/etc/vconsole.conf").content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_efi_mount_permissions_tightened(host):
+ # archsetup adds fmask/dmask to the /efi vfat line so it isn't world-readable.
+ fstab = host.file("/etc/fstab").content_string
+ efi_lines = [
+ ln for ln in fstab.splitlines()
+ if ln.strip() and not ln.lstrip().startswith("#")
+ and " /efi " in ln and " vfat " in ln
+ ]
+ if not efi_lines:
+ pytest.skip("no /efi vfat line in fstab")
+ assert all("fmask=" in ln for ln in efi_lines), "/efi mount not permission-tightened"
diff --git a/scripts/testing/tests/test_keyring.py b/scripts/testing/tests/test_keyring.py
new file mode 100644
index 0000000..99d322d
--- /dev/null
+++ b/scripts/testing/tests/test_keyring.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: gnome-keyring pre-configuration.
+
+Parity port of validate_gnome_keyring_setup: the keyrings dir must exist, be
+mode 700, owned by the user, with the default keyring set to "login".
+"""
+
+import pytest
+
+
+@pytest.fixture(scope="session")
+def keyring_dir(home):
+ return "%s/.local/share/keyrings" % home
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_exists(host, keyring_dir):
+ assert host.file(keyring_dir).is_directory
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_mode_700(host, keyring_dir):
+ assert host.file(keyring_dir).mode == 0o700
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_owned_by_user(host, keyring_dir, target_user):
+ assert host.file(keyring_dir).user == target_user
+
+
+@pytest.mark.attribution("archsetup")
+def test_default_keyring_is_login(host, keyring_dir):
+ default = host.file("%s/default" % keyring_dir)
+ assert default.exists, "default keyring file missing"
+ assert default.content_string.strip() == "login"
diff --git a/scripts/testing/tests/test_packages.py b/scripts/testing/tests/test_packages.py
new file mode 100644
index 0000000..f237088
--- /dev/null
+++ b/scripts/testing/tests/test_packages.py
@@ -0,0 +1,60 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: package managers and key packages.
+
+Parity port of validate_yay_installed, validate_pacman_working,
+validate_terminus_font, validate_emacs, validate_git_config, validate_dev_tools.
+"""
+
+import pytest
+
+
+DEV_TOOLS = ["python", "node", "npm", "go", "rustc"]
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_yay_installed(host):
+ assert host.exists("yay"), "yay binary not on PATH"
+
+
+@pytest.mark.attribution("archsetup")
+def test_yay_functional(host, target_user):
+ # yay must actually query the package DB as the user, not just exist.
+ assert host.run("sudo -u %s yay -Qi yay" % target_user).rc == 0
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("unknown")
+def test_pacman_functional(host):
+ assert host.package("base").is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_terminus_font_installed(host):
+ assert host.package("terminus-font").is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_emacs_installed(host):
+ assert host.exists("emacs")
+
+
+@pytest.mark.attribution("archsetup")
+def test_emacs_config_readable_by_user(host, target_user, home):
+ emacsd = host.file("%s/.emacs.d" % home)
+ if not emacsd.exists:
+ pytest.skip(".emacs.d not present (config dir optional on some profiles)")
+ assert emacsd.is_directory
+ assert host.run("sudo -u %s ls %s" % (target_user, emacsd.path)).rc == 0
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_git_installed(host):
+ assert host.exists("git")
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("tool", DEV_TOOLS)
+def test_dev_tool_present(host, tool):
+ assert host.exists(tool), "dev tool %s missing from PATH" % tool
diff --git a/scripts/testing/tests/test_services.py b/scripts/testing/tests/test_services.py
new file mode 100644
index 0000000..0ca3970
--- /dev/null
+++ b/scripts/testing/tests/test_services.py
@@ -0,0 +1,103 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: services, timers, and their functional health.
+
+Parity port of validate_firewall, validate_dns_config, validate_avahi,
+validate_fail2ban, validate_networkmanager, and validate_all_services /
+validate_service_functions.
+
+Mapping of the shell sweep's three outcomes:
+ - validation_fail (hard) -> assert
+ - validation_warn (soft) -> pytest.skip with the reason (visible, never red)
+ - validation_skip (precond)-> pytest.skip gated on a fixture
+"""
+
+import pytest
+
+
+# Required services: (name, must_be_active). ufw can't activate in the VM (no
+# iptables kernel modules), so it's enabled-only; cronie/atd are enabled-only too.
+REQUIRED_ENABLED_ACTIVE = ["sshd", "systemd-resolved", "fail2ban", "NetworkManager", "rngd"]
+REQUIRED_ENABLED_ONLY = ["ufw", "cronie", "atd"]
+REQUIRED_TIMERS = ["reflector.timer", "paccache.timer"]
+OPTIONAL_SERVICES = ["avahi-daemon", "bluetooth", "cups", "docker", "tailscaled"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("svc", REQUIRED_ENABLED_ACTIVE)
+def test_required_service_enabled_and_active(host, svc):
+ s = host.service(svc)
+ assert s.is_enabled, "%s should be enabled" % svc
+ assert s.is_running, "%s should be active" % svc
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("svc", REQUIRED_ENABLED_ONLY)
+def test_required_service_enabled(host, svc):
+ assert host.service(svc).is_enabled, "%s should be enabled" % svc
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("timer", REQUIRED_TIMERS)
+def test_required_timer_enabled(host, timer):
+ assert host.service(timer).is_enabled, "%s should be enabled" % timer
+
+
+@pytest.mark.parametrize("svc", OPTIONAL_SERVICES)
+def test_optional_service(host, svc):
+ # Optional: warn-if-missing in the shell sweep -> skip here so it never reds.
+ if not host.service(svc).is_enabled:
+ pytest.skip("%s not enabled (optional)" % svc)
+
+
+@pytest.mark.attribution("archsetup")
+def test_dns_over_tls_dropin_present(host):
+ # archsetup ships /etc/systemd/resolved.conf.d/dns-over-tls.conf.
+ assert host.file("/etc/systemd/resolved.conf.d/dns-over-tls.conf").exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_fail2ban_responds(host):
+ assert host.run("fail2ban-client status").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_networkmanager_responds(host):
+ assert host.run("nmcli general status").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_log_cleanup_cron_installed(host, target_user):
+ out = host.run("sudo -u %s crontab -l" % target_user).stdout
+ assert "log-cleanup" in out, "log-cleanup entry missing from user crontab"
+
+
+@pytest.mark.attribution("archsetup")
+def test_syncthing_user_lingering_enabled(host, target_user):
+ # syncthing runs as a user service; lingering must be on for autostart.
+ assert host.file("/var/lib/systemd/linger/%s" % target_user).exists
+
+
+def test_dns_resolution(host):
+ # Network-dependent; advisory in the shell sweep. Skip on failure.
+ if host.run("resolvectl query archlinux.org").rc != 0:
+ pytest.skip("DNS resolution query failed (network-dependent)")
+
+
+def test_mdns_resolves(host, on_slirp):
+ # mDNS needs multicast, which QEMU slirp doesn't pass.
+ if on_slirp:
+ pytest.skip("mDNS not possible on slirp networking (no multicast)")
+ if not host.service("avahi-daemon").is_enabled:
+ pytest.skip("avahi-daemon not enabled")
+ hostname = host.run("hostname").stdout.strip()
+ assert host.run("ping -c 1 -W 2 %s.local" % hostname).rc == 0
+
+
+def test_docker_functional(host):
+ if not host.service("docker").is_enabled:
+ pytest.skip("docker not enabled")
+ if not host.service("docker").is_running:
+ # archsetup enables docker for next boot, not --now; pre-reboot this is correct.
+ pytest.skip("docker enabled but not started (starts on boot by design)")
+ assert host.run("docker info").rc == 0
diff --git a/scripts/testing/tests/test_users.py b/scripts/testing/tests/test_users.py
new file mode 100644
index 0000000..c0097ed
--- /dev/null
+++ b/scripts/testing/tests/test_users.py
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: the user account archsetup creates.
+
+Parity port of validate_user_created / validate_user_shell / validate_user_groups.
+"""
+
+import pytest
+
+
+# Groups archsetup adds: wheel (useradd -G), the usermod -aG set, and docker
+# (added later in the developer-workstation step).
+EXPECTED_GROUPS = [
+ "wheel", "sys", "adm", "network", "scanner", "power", "uucp",
+ "audio", "lp", "rfkill", "video", "storage", "optical", "users", "docker",
+]
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_user_exists(host, target_user):
+ assert host.user(target_user).exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_user_shell_is_zsh(host, target_user):
+ # archsetup may set either path depending on how zsh resolves.
+ assert host.user(target_user).shell in ("/bin/zsh", "/usr/bin/zsh")
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("group", EXPECTED_GROUPS)
+def test_user_in_group(host, target_user, group):
+ # Parametrized so a failure names the exact missing group.
+ assert group in host.user(target_user).groups