"""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()