aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_configure_autologin.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/installer-steps/test_configure_autologin.py')
-rw-r--r--tests/installer-steps/test_configure_autologin.py218
1 files changed, 218 insertions, 0 deletions
diff --git a/tests/installer-steps/test_configure_autologin.py b/tests/installer-steps/test_configure_autologin.py
new file mode 100644
index 0000000..b4bc1c4
--- /dev/null
+++ b/tests/installer-steps/test_configure_autologin.py
@@ -0,0 +1,218 @@
+"""Tests for configure_autologin in the archsetup installer.
+
+configure_autologin decides whether tty1 gets an agetty --autologin drop-in.
+Three inputs decide it: --autologin / --no-autologin (or AUTOLOGIN=yes/no in
+the config file), which are explicit; and, when neither is set, an auto-detect
+branch that prompts -- but only on an encrypted root, where the user has
+already authenticated at boot.
+
+That prompt is the reason these tests exist. --config-file is the documented
+unattended mode and AUTOLOGIN is optional in archsetup.conf.example, so an
+unattended install of an encrypted machine reaches an interactive read with
+nobody there to answer it. The step runs from boot_ux, the last step in STEPS,
+so it blocks at the end of a 40-60 minute run. The prompt is advisory (it
+carries its own [Y/n] default), so unattended must answer it rather than block
+-- the same ruling nvidia_preflight already makes for its rc-10 prompt.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), with is_encrypted_root stubbed to a chosen
+answer and the drop-in written under a temp dir.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_configure_autologin
+"""
+
+import os
+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")
+
+
+class ConfigureAutologinHarness(unittest.TestCase):
+ """Source configure_autologin out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="autologin-test-")
+ self.dropin_dir = os.path.join(self.tmp, "getty@tty1.service.d")
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^configure_autologin() {/,/^}/p' \"$ARCHSETUP\")\n"
+ # The step's only other collaborators: a display helper and the
+ # encryption probe. Both stubbed so the body is what runs.
+ 'display() { echo "DISPLAY:$2"; }\n'
+ "is_encrypted_root() { return \"${STUB_ENCRYPTED_RC:-1}\"; }\n"
+ 'configure_autologin "$AUTOLOGIN_DIR"\n'
+ 'echo "RETURNED=$?"\n'
+ # Whatever the prompt did not consume is still on stdin.
+ 'if IFS= read -r leftover; then echo "LEFTOVER:$leftover"; '
+ 'else echo "LEFTOVER:"; fi\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_step(self, enable_autologin="", encrypted=False, config_file="",
+ stdin="", username="testuser"):
+ env = dict(os.environ)
+ env["enable_autologin"] = enable_autologin
+ env["config_file"] = config_file
+ env["username"] = username
+ env["AUTOLOGIN_DIR"] = self.dropin_dir
+ env["STUB_ENCRYPTED_RC"] = "0" if encrypted else "1"
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP],
+ input=stdin, capture_output=True, text=True, env=env,
+ )
+
+ def dropin(self):
+ """The written drop-in's contents, or None when it wasn't written."""
+ path = os.path.join(self.dropin_dir, "autologin.conf")
+ if not os.path.exists(path):
+ return None
+ with open(path) as f:
+ return f.read()
+
+ def assertReturnedZero(self, r):
+ self.assertIn("RETURNED=0", r.stdout,
+ "step did not return 0; stdout=%r stderr=%r"
+ % (r.stdout, r.stderr))
+
+ # ---------------------------------------------------------- normal ----
+ def test_explicit_true_writes_dropin_without_prompting(self):
+ r = self.run_step(enable_autologin="true", stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_explicit_false_writes_nothing_without_prompting(self):
+ r = self.run_step(enable_autologin="false", encrypted=True,
+ stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_autodetect_unencrypted_root_skips_silently(self):
+ r = self.run_step(encrypted=False, stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ # -------------------------------------------------------- boundary ----
+ def test_autodetect_encrypted_interactive_yes_writes_dropin(self):
+ # bash emits `read -p`'s prompt only when stdin is a terminal, so the
+ # prompt text is unassertable here. What is assertable is that the read
+ # consumed exactly the answer line and left the rest of stdin alone.
+ r = self.run_step(encrypted=True, stdin="y\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_autodetect_encrypted_bare_enter_takes_the_yes_default(self):
+ r = self.run_step(encrypted=True, stdin="\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_config_file_does_not_block_on_the_prompt(self):
+ # --config-file is the documented unattended mode and AUTOLOGIN is
+ # optional in archsetup.conf.example, so this is the ordinary
+ # unattended install of an encrypted machine. It must not read stdin.
+ r = self.run_step(encrypted=True, config_file="/etc/archsetup.conf",
+ stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_leaves_autologin_off(self):
+ # A prompt default is calibrated for someone who can override it in the
+ # moment. Unattended there is no override, so the default stops being
+ # advisory and becomes policy -- and this policy is passwordless console
+ # login. "Encrypted root means already authenticated" holds only at
+ # boot: it does not hold for a stolen powered-on machine, where tty1 is
+ # one Ctrl-Alt-F1 away and a screen locker does not lock the console.
+ #
+ # The error costs are asymmetric. Wrongly ON is a silent authentication
+ # downgrade nobody notices. Wrongly OFF is one password prompt, noticed
+ # immediately, fixed with a config key that already exists.
+ r = self.run_step(encrypted=True, config_file="/etc/archsetup.conf")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+
+ def test_unattended_says_why_it_left_autologin_off(self):
+ # Silently declining is its own trap: the user gets a login prompt they
+ # did not expect and no idea which knob controls it.
+ r = self.run_step(encrypted=True, config_file="/etc/archsetup.conf")
+ self.assertIn("AUTOLOGIN", r.stdout)
+
+ def test_unattended_explicit_yes_still_enables_it(self):
+ # Declining by default must not make the feature unreachable
+ # unattended. AUTOLOGIN=yes is the opt-in and it still works.
+ r = self.run_step(enable_autologin="true", encrypted=True,
+ config_file="/etc/archsetup.conf", stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIn("--autologin testuser", self.dropin() or "")
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_explicit_no_still_wins_over_the_default(self):
+ # Unattended does not mean "always autologin" -- AUTOLOGIN=no is an
+ # answer, and the auto-detect default must not override it.
+ r = self.run_step(enable_autologin="false", encrypted=True,
+ config_file="/etc/archsetup.conf", stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_unattended_unencrypted_root_still_writes_nothing(self):
+ r = self.run_step(encrypted=False, config_file="/etc/archsetup.conf",
+ stdin="sentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ # ----------------------------------------------------------- error ----
+ def test_autodetect_encrypted_interactive_no_writes_nothing(self):
+ r = self.run_step(encrypted=True, stdin="n\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_autodetect_encrypted_interactive_capital_no_writes_nothing(self):
+ r = self.run_step(encrypted=True, stdin="No\nsentinel\n")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+ self.assertIn("LEFTOVER:sentinel", r.stdout)
+
+ def test_dropin_overrides_the_packaged_execstart(self):
+ # A drop-in that adds a second ExecStart without clearing the first is
+ # a unit systemd refuses to start -- tty1 would have no getty at all.
+ r = self.run_step(enable_autologin="true")
+ self.assertReturnedZero(r)
+ body = self.dropin() or ""
+ self.assertIn("ExecStart=\n", body)
+ self.assertLess(body.index("ExecStart=\n"),
+ body.index("ExecStart=-/sbin/agetty"))
+
+ def test_closed_stdin_leaves_autologin_off(self):
+ # The same "nobody can answer" state reached without --config-file: a
+ # piped or redirected stdin at EOF. Pre-fix the read failed, response
+ # was empty, and `case *` enabled autologin silently.
+ r = self.run_step(encrypted=True, stdin="")
+ self.assertReturnedZero(r)
+ self.assertIsNone(self.dropin())
+
+ def test_closed_stdin_says_why(self):
+ r = self.run_step(encrypted=True, stdin="")
+ self.assertIn("stdin closed", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()