225 lines
11 KiB
Python
225 lines
11 KiB
Python
"""Opt-in startup tests use only owned temporary homes and harmless stubs."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shlex
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "expanded"))
|
|
from framebridge_expanded import console_startup as startup
|
|
|
|
|
|
@unittest.skipIf(os.geteuid() == 0, "Unprivileged user profile tests")
|
|
class ConsoleStartupTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix="fb-login-startup-")
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name)
|
|
self.home = self.root / "home"
|
|
self.home.mkdir(mode=0o700)
|
|
self.state = self.root / "state"
|
|
|
|
def existing(self, name=".profile", data=b"# user's existing startup\n", mode=0o640):
|
|
path = self.home / name
|
|
path.write_bytes(data)
|
|
path.chmod(mode)
|
|
return path
|
|
|
|
def assert_ok(self, value):
|
|
self.assertTrue(value.get("ok"), value)
|
|
return value
|
|
|
|
def manifest(self):
|
|
return json.loads((self.state / "console-login/manifest.json").read_text())
|
|
|
|
def test_existing_bytes_mode_and_exact_private_backup_restore(self):
|
|
data = b"printf '%s' 'existing login content'"
|
|
path = self.existing(".bash_profile", data)
|
|
result = self.assert_ok(startup.enable(self.state, home=self.home))
|
|
self.assertTrue(result["enabled"])
|
|
self.assertEqual(result["startup_file"], str(path))
|
|
self.assertTrue(path.read_bytes().startswith(data + b"\n"))
|
|
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
|
|
record = self.manifest()
|
|
backup = self.state / "console-login" / record["backup_name"]
|
|
self.assertEqual(backup.read_bytes(), data)
|
|
self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o600)
|
|
self.assertTrue(self.assert_ok(startup.status(self.state, home=self.home))["enabled"])
|
|
result = self.assert_ok(startup.disable(self.state, home=self.home))
|
|
self.assertFalse(result["enabled"])
|
|
self.assertEqual(path.read_bytes(), data)
|
|
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
|
|
|
|
def test_effective_bash_startup_priority(self):
|
|
self.existing(".profile", b"# lower priority\n")
|
|
login = self.existing(".bash_login", b"# selected login\n")
|
|
result = self.assert_ok(startup.enable(self.state, home=self.home))
|
|
self.assertEqual(result["startup_file"], str(login))
|
|
self.assertEqual((self.home / ".profile").read_bytes(), b"# lower priority\n")
|
|
|
|
def test_new_profile_is_removed_on_disable(self):
|
|
self.assertTrue(self.assert_ok(startup.enable(self.state, home=self.home))["changed"])
|
|
path = self.home / ".profile"
|
|
self.assertTrue(path.is_file())
|
|
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
|
self.assert_ok(startup.disable(self.state, home=self.home))
|
|
self.assertFalse(path.exists())
|
|
|
|
def test_enable_and_disable_are_idempotent(self):
|
|
path = self.existing()
|
|
self.assert_ok(startup.enable(self.state, home=self.home))
|
|
installed = path.read_bytes()
|
|
again = self.assert_ok(startup.enable(self.state, home=self.home))
|
|
self.assertFalse(again["changed"])
|
|
self.assertEqual(path.read_bytes(), installed)
|
|
self.assertEqual(len(list((self.state / "console-login").glob("backup-*.bin"))), 1)
|
|
self.assert_ok(startup.disable(self.state, home=self.home))
|
|
self.assertFalse(self.assert_ok(startup.disable(self.state, home=self.home))["changed"])
|
|
|
|
def test_reenable_preserves_previous_recovery_history(self):
|
|
path = self.existing(data=b"# baseline one\n")
|
|
self.assert_ok(startup.enable(self.state, home=self.home))
|
|
self.assert_ok(startup.disable(self.state, home=self.home))
|
|
path.write_bytes(b"# baseline two\n")
|
|
self.assert_ok(startup.enable(self.state, home=self.home))
|
|
self.assertEqual(len(list((self.state / "console-login").glob("backup-*.bin"))), 2)
|
|
self.assertEqual(len(list((self.state / "console-login").glob("history-*.json"))), 1)
|
|
self.assert_ok(startup.disable(self.state, home=self.home))
|
|
self.assertEqual(path.read_bytes(), b"# baseline two\n")
|
|
|
|
def test_later_edits_are_never_overwritten(self):
|
|
path = self.existing()
|
|
self.assert_ok(startup.enable(self.state, home=self.home))
|
|
path.write_bytes(path.read_bytes() + b"# later user change\n")
|
|
changed = path.read_bytes()
|
|
for operation in (startup.disable, startup.enable, startup.status):
|
|
reply = operation(self.state, home=self.home)
|
|
self.assertFalse(reply["ok"])
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_STARTUP_CHANGED")
|
|
self.assertEqual(path.read_bytes(), changed)
|
|
|
|
def test_symlink_and_nonregular_startup_are_refused(self):
|
|
target = self.root / "outside"
|
|
target.write_bytes(b"# unchanged outside\n")
|
|
(self.home / ".bash_profile").symlink_to(target)
|
|
reply = startup.enable(self.state, home=self.home)
|
|
self.assertFalse(reply["ok"])
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_SYMLINK_REFUSED")
|
|
self.assertEqual(target.read_bytes(), b"# unchanged outside\n")
|
|
(self.home / ".bash_profile").unlink()
|
|
(self.home / ".bash_profile").mkdir()
|
|
self.assertEqual(startup.enable(self.state, home=self.home)["error"],
|
|
"CONSOLE_LOGIN_FILE_NOT_REGULAR")
|
|
|
|
def test_competing_profile_is_refused(self):
|
|
path = self.existing()
|
|
self.assert_ok(startup.enable(self.state, home=self.home))
|
|
installed = path.read_bytes()
|
|
other = self.root / "other-state"
|
|
reply = startup.enable(other, home=self.home)
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_OTHER_PROFILE_ENABLED")
|
|
self.assertEqual(path.read_bytes(), installed)
|
|
self.assertFalse((other / "console-login/manifest.json").exists())
|
|
|
|
def test_foreign_untracked_block_is_refused(self):
|
|
path = self.existing(data=b"# BEGIN FRAMEBRIDGE CONSOLE LOGIN v1 foreign\n")
|
|
before = path.read_bytes()
|
|
reply = startup.enable(self.state, home=self.home)
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_OTHER_PROFILE_ENABLED")
|
|
self.assertEqual(path.read_bytes(), before)
|
|
|
|
def test_priority_change_is_reported_but_own_hook_can_be_removed(self):
|
|
path = self.existing(data=b"# restored profile\n")
|
|
self.assert_ok(startup.enable(self.state, home=self.home))
|
|
higher = self.existing(".bash_profile", b"# newly preferred file\n")
|
|
reply = startup.status(self.state, home=self.home)
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_STARTUP_PRIORITY_CHANGED")
|
|
self.assert_ok(startup.disable(self.state, home=self.home))
|
|
self.assertEqual(path.read_bytes(), b"# restored profile\n")
|
|
self.assertEqual(higher.read_bytes(), b"# newly preferred file\n")
|
|
|
|
def test_modified_backup_refuses_restore(self):
|
|
path = self.existing()
|
|
self.assert_ok(startup.enable(self.state, home=self.home))
|
|
installed = path.read_bytes()
|
|
record = self.manifest()
|
|
backup = self.state / "console-login" / record["backup_name"]
|
|
backup.write_bytes(b"# tampered recovery\n")
|
|
reply = startup.disable(self.state, home=self.home)
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_BACKUP_MISMATCH")
|
|
self.assertEqual(path.read_bytes(), installed)
|
|
|
|
def test_state_symlink_is_refused(self):
|
|
real = self.root / "real-state"
|
|
real.mkdir(mode=0o700)
|
|
self.state.symlink_to(real, target_is_directory=True)
|
|
reply = startup.enable(self.state, home=self.home)
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_SYMLINK_REFUSED")
|
|
self.assertFalse((real / "console-login").exists())
|
|
|
|
def test_status_has_no_write_side_effects(self):
|
|
reply = self.assert_ok(startup.status(self.state, home=self.home))
|
|
self.assertFalse(reply["enabled"])
|
|
self.assertFalse(self.state.exists())
|
|
self.assertFalse((self.home / ".framebridge-console-login.lock").exists())
|
|
|
|
def test_default_home_uses_passwd_not_profile_environment_home(self):
|
|
with mock.patch.object(startup.pwd, "getpwuid",
|
|
return_value=type("Identity", (), {"pw_dir": str(self.home)})()):
|
|
with mock.patch.dict(os.environ, {"HOME": str(self.root / "wrong-home")}):
|
|
reply = self.assert_ok(startup.enable(self.state))
|
|
self.assertEqual(reply["startup_file"], str(self.home / ".profile"))
|
|
|
|
def test_login_guards_and_quoted_state_are_executed_in_fixture_only(self):
|
|
fixture_bin = self.root / "bin"
|
|
fixture_bin.mkdir()
|
|
tty = fixture_bin / "tty"
|
|
tty.write_text("#!/bin/sh\nprintf '%s\\n' \"$" + "{FB_TEST_TTY-/dev/tty1}\"\n")
|
|
tty.chmod(0o700)
|
|
marker = self.root / "invocation.json"
|
|
stub = fixture_bin / "fb-stub"
|
|
stub.write_text("#!/usr/bin/python3\nimport json,sys\nfrom pathlib import Path\n"
|
|
+ "Path(" + repr(str(marker)) + ").write_text(json.dumps(sys.argv[1:]))\n")
|
|
stub.chmod(0o700)
|
|
weird = self.root / "state with ' quote $(touch SHOULD_NOT_EXIST)"
|
|
with mock.patch.object(startup, "FB_EXECUTABLE", str(stub)):
|
|
self.assert_ok(startup.enable(weird, home=self.home))
|
|
profile = self.home / ".profile"
|
|
subprocess.run(["/bin/sh", "-n", str(profile)], check=True)
|
|
base_env = dict(os.environ, PATH=str(fixture_bin) + ":" + os.environ["PATH"],
|
|
SSH_TTY="", TMUX="", FB_TEST_TTY="/dev/tty1")
|
|
command = ["/bin/bash", "--noprofile", "--norc", "--login", "-c",
|
|
". " + shlex.quote(str(profile))]
|
|
for override in ({"SSH_TTY": "/dev/pts/9"}, {"TMUX": "test-client"},
|
|
{"FB_TEST_TTY": "/dev/tty2"}):
|
|
subprocess.run(command, env=dict(base_env, **override), check=True,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
self.assertFalse(marker.exists())
|
|
nonlogin = ["/bin/bash", "--noprofile", "--norc", "-c",
|
|
". " + shlex.quote(str(profile))]
|
|
subprocess.run(nonlogin, env=base_env, check=True,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
self.assertFalse(marker.exists())
|
|
subprocess.run(command, env=base_env, check=True,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
self.assertEqual(json.loads(marker.read_text()),
|
|
["--state-dir", str(weird), "console", "--mode", "tty"])
|
|
self.assertFalse((self.root / "SHOULD_NOT_EXIST").exists())
|
|
|
|
|
|
class UserOnlyTests(unittest.TestCase):
|
|
def test_root_context_is_rejected_before_any_path_change(self):
|
|
with mock.patch.object(startup.os, "geteuid", return_value=0):
|
|
reply = startup.enable("/does/not/exist", home="/also/not/used")
|
|
self.assertEqual(reply["error"], "CONSOLE_LOGIN_USER_REQUIRED")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|