refactor!: port shell scripts to Python package
Bash scripts were untestable and duplicated device/LUKS/mount logic; the lim/ package centralizes it behind one subprocess wrapper and a YAML image catalog (single point of truth). BREAKING CHANGE: scripts/*.sh removed. Use `lim --type <cmd>`; new types mount/umount/single-boot/raid1-boot/lock/unlock/import/export replace direct script calls. --extra is deprecated and ignored. - distributions.yml + lim/catalog.py hold the image catalog (PyYAML) - pytest suite: 102 tests with mocked subprocess (tests/unit) and a 250-line max file-length guard (tests/lint) - ruff strict (select ALL), GitHub Actions CI, Dependabot; Travis gone - Makefile: install (symlink ~/.local/bin/lim) and test targets - fixes over bash: SUDO_USER-aware chown, mmcblk/nvme partition paths, sha512 checksum support, whole-pipeline failure detection, blkid UUID fallback for pre-mounted images, conditional fstab seeding for PARTUUID/LABEL images, clean errors for missing binaries
This commit is contained in:
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
24
tests/unit/test_catalog.py
Normal file
24
tests/unit/test_catalog.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from lim import catalog
|
||||
|
||||
|
||||
def test_catalog_contains_all_sections():
|
||||
assert catalog.arch_rpi_images()
|
||||
assert catalog.manjaro_gnome_releases()
|
||||
assert catalog.retropie_images()
|
||||
assert catalog.mkinitcpio_modules_by_rpi()
|
||||
|
||||
|
||||
def test_arch_entries_are_complete():
|
||||
for version, entry in catalog.arch_rpi_images().items():
|
||||
assert entry["image"], version
|
||||
assert entry["luks_memory_cost"].isdigit(), version
|
||||
|
||||
|
||||
def test_mkinitcpio_covers_every_arch_rpi_version():
|
||||
assert set(catalog.mkinitcpio_modules_by_rpi()) == set(catalog.arch_rpi_images())
|
||||
|
||||
|
||||
def test_manjaro_entries_have_url_and_image():
|
||||
for release, entry in catalog.manjaro_gnome_releases().items():
|
||||
assert entry["url"].startswith("https://"), release
|
||||
assert entry["image"], release
|
||||
65
tests/unit/test_cli.py
Normal file
65
tests/unit/test_cli.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
|
||||
from lim import cli, system
|
||||
from lim.cli import Command
|
||||
from lim.errors import LimError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_input(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"builtins.input", lambda *args: (_ for _ in ()).throw(AssertionError("unexpected prompt"))
|
||||
)
|
||||
|
||||
|
||||
def test_every_command_dispatches_to_its_function(monkeypatch):
|
||||
executed = []
|
||||
monkeypatch.setitem(
|
||||
cli.COMMANDS, "lock", Command(lambda: executed.append("lock"), "d", needs_root=False)
|
||||
)
|
||||
cli.main(["--type", "lock", "--auto-confirm"])
|
||||
assert executed == ["lock"]
|
||||
|
||||
|
||||
def test_needs_root_command_requests_root(monkeypatch):
|
||||
escalated = []
|
||||
monkeypatch.setattr(system, "ensure_root", lambda: escalated.append(True))
|
||||
monkeypatch.setitem(
|
||||
cli.COMMANDS, "backup", Command(lambda: None, "d", needs_root=True)
|
||||
)
|
||||
cli.main(["--type", "backup", "--auto-confirm"])
|
||||
assert escalated == [True]
|
||||
|
||||
|
||||
def test_confirmation_prompt_waits_for_enter(monkeypatch):
|
||||
prompts = []
|
||||
executed = []
|
||||
monkeypatch.setattr("builtins.input", lambda *args: prompts.append(args) or "")
|
||||
monkeypatch.setitem(
|
||||
cli.COMMANDS, "lock", Command(lambda: executed.append(True), "d", needs_root=False)
|
||||
)
|
||||
cli.main(["--type", "lock"])
|
||||
assert len(prompts) == 1
|
||||
assert executed == [True]
|
||||
|
||||
|
||||
def test_lim_error_exits_with_code_1(monkeypatch):
|
||||
def failing():
|
||||
raise LimError("boom")
|
||||
|
||||
monkeypatch.setitem(cli.COMMANDS, "lock", Command(failing, "d", needs_root=False))
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cli.main(["--type", "lock", "--auto-confirm"])
|
||||
assert excinfo.value.code == 1
|
||||
|
||||
|
||||
def test_unknown_type_is_rejected_by_argparse():
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cli.main(["--type", "does-not-exist"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
def test_all_registered_commands_have_descriptions():
|
||||
for name, command in cli.COMMANDS.items():
|
||||
assert command.description, name
|
||||
assert callable(command.func), name
|
||||
84
tests/unit/test_device.py
Normal file
84
tests/unit/test_device.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
|
||||
from lim import device
|
||||
from lim.device import Device
|
||||
from lim.errors import LimError
|
||||
|
||||
|
||||
class TestPartitionNaming:
|
||||
def test_letter_suffix_devices_append_number(self):
|
||||
assert Device("sda").partition(1) == "/dev/sda1"
|
||||
assert Device("sdb").partition(2) == "/dev/sdb2"
|
||||
|
||||
def test_digit_suffix_devices_get_p_infix(self):
|
||||
assert Device("mmcblk0").partition(1) == "/dev/mmcblk0p1"
|
||||
assert Device("nvme0n1").partition(2) == "/dev/nvme0n1p2"
|
||||
|
||||
|
||||
class TestOptimalBlocksize:
|
||||
def test_uses_64_times_physical_block_size(self, tmp_path):
|
||||
queue = tmp_path / "sda" / "queue"
|
||||
queue.mkdir(parents=True)
|
||||
(queue / "physical_block_size").write_text("512\n")
|
||||
assert device.optimal_blocksize("sda", tmp_path) == str(64 * 512)
|
||||
|
||||
def test_falls_back_to_4k_when_missing(self, tmp_path):
|
||||
assert device.optimal_blocksize("sda", tmp_path) == "4K"
|
||||
|
||||
def test_falls_back_to_4k_on_garbage(self, tmp_path):
|
||||
queue = tmp_path / "sda" / "queue"
|
||||
queue.mkdir(parents=True)
|
||||
(queue / "physical_block_size").write_text("not-a-number")
|
||||
assert device.optimal_blocksize("sda", tmp_path) == "4K"
|
||||
|
||||
|
||||
class TestOverwriteDevice:
|
||||
@pytest.fixture
|
||||
def sda(self, monkeypatch):
|
||||
monkeypatch.setattr(Device, "optimal_blocksize", property(lambda self: "4K"))
|
||||
return Device("sda")
|
||||
|
||||
def test_full_overwrite(self, sda, fake_runner, answers):
|
||||
answers("y")
|
||||
device.overwrite_device(sda)
|
||||
dd_calls = fake_runner.find("dd", "if=/dev/zero", "of=/dev/sda")
|
||||
assert len(dd_calls) == 1
|
||||
assert not any("count=" in part for part in dd_calls[0])
|
||||
|
||||
@pytest.mark.parametrize("answer", ["", "N"])
|
||||
def test_skip(self, sda, fake_runner, answers, answer):
|
||||
answers(answer)
|
||||
device.overwrite_device(sda)
|
||||
assert fake_runner.find("dd") == []
|
||||
|
||||
def test_block_count(self, sda, fake_runner, answers):
|
||||
answers("34")
|
||||
device.overwrite_device(sda)
|
||||
assert len(fake_runner.find("dd", "count=34", "bs=4K")) == 1
|
||||
|
||||
def test_invalid_input_raises(self, sda, fake_runner, answers):
|
||||
answers("nonsense")
|
||||
with pytest.raises(LimError):
|
||||
device.overwrite_device(sda)
|
||||
|
||||
|
||||
class TestSelectDevice:
|
||||
def test_valid_device(self, fake_runner, answers, monkeypatch):
|
||||
answers("sda")
|
||||
monkeypatch.setattr(device, "is_block_device", lambda path: path == "/dev/sda")
|
||||
assert device.select_device() == Device("sda")
|
||||
|
||||
def test_invalid_device_raises(self, fake_runner, answers, monkeypatch):
|
||||
answers("nope")
|
||||
monkeypatch.setattr(device, "is_block_device", lambda path: False)
|
||||
with pytest.raises(LimError):
|
||||
device.select_device()
|
||||
|
||||
|
||||
def test_is_mounted(fake_runner):
|
||||
fake_runner.outputs["mount"] = (
|
||||
"/dev/sda1 on /boot type vfat (rw)\n/dev/mapper/x on /media/x type btrfs (rw)"
|
||||
)
|
||||
assert device.is_mounted("/dev/sda1")
|
||||
assert device.is_mounted("/media/x")
|
||||
assert not device.is_mounted("/dev/sdb")
|
||||
35
tests/unit/test_fsutil.py
Normal file
35
tests/unit/test_fsutil.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from lim import fsutil
|
||||
from lim.errors import LimError
|
||||
|
||||
|
||||
def test_replace_in_file_replaces_all_occurrences(tmp_path):
|
||||
target = tmp_path / "conf"
|
||||
target.write_text("MODULES=()\nHOOKS=(base)\nMODULES=()\n")
|
||||
fsutil.replace_in_file("MODULES=()", "MODULES=(x)", target)
|
||||
assert target.read_text() == "MODULES=(x)\nHOOKS=(base)\nMODULES=(x)\n"
|
||||
|
||||
|
||||
def test_replace_in_file_fails_when_search_missing(tmp_path):
|
||||
target = tmp_path / "conf"
|
||||
target.write_text("nothing here\n")
|
||||
with pytest.raises(LimError):
|
||||
fsutil.replace_in_file("MODULES=()", "MODULES=(x)", target)
|
||||
assert target.read_text() == "nothing here\n"
|
||||
|
||||
|
||||
def test_ensure_line_appends_once(tmp_path):
|
||||
target = tmp_path / "fstab"
|
||||
target.write_text("existing entry\n")
|
||||
assert fsutil.ensure_line_in_file("new entry", target) is True
|
||||
assert fsutil.ensure_line_in_file("new entry", target) is False
|
||||
assert target.read_text() == "existing entry\nnew entry\n"
|
||||
|
||||
|
||||
def test_ensure_line_creates_file_and_handles_missing_newline(tmp_path):
|
||||
target = tmp_path / "crypttab"
|
||||
assert fsutil.ensure_line_in_file("first", target) is True
|
||||
target.write_text("no newline at end")
|
||||
assert fsutil.ensure_line_in_file("second", target) is True
|
||||
assert target.read_text() == "no newline at end\nsecond\n"
|
||||
62
tests/unit/test_image_session.py
Normal file
62
tests/unit/test_image_session.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from lim.device import Device
|
||||
from lim.image.session import ImageSession
|
||||
|
||||
|
||||
def test_partition_paths_for_sd_card():
|
||||
session = ImageSession(Device("mmcblk0"))
|
||||
assert session.boot_partition_path == "/dev/mmcblk0p1"
|
||||
assert session.root_partition_path == "/dev/mmcblk0p2"
|
||||
assert session.root_mapper_path == "/dev/mmcblk0p2"
|
||||
|
||||
|
||||
def test_decrypt_root_on_luks_partition(fake_runner):
|
||||
fake_runner.outputs["-s TYPE"] = "crypto_LUKS"
|
||||
fake_runner.outputs["-s UUID"] = "uuid-1"
|
||||
session = ImageSession(Device("sda"))
|
||||
session.decrypt_root()
|
||||
assert session.root_mapper_name == "linux-image-manager-uuid-1"
|
||||
assert session.root_mapper_path == "/dev/mapper/linux-image-manager-uuid-1"
|
||||
assert len(fake_runner.find("cryptsetup", "luksOpen", "/dev/sda2")) == 1
|
||||
|
||||
|
||||
def test_decrypt_root_skips_plain_partition(fake_runner):
|
||||
fake_runner.outputs["-s TYPE"] = "ext4"
|
||||
session = ImageSession(Device("sda"))
|
||||
session.decrypt_root()
|
||||
assert session.root_mapper_name is None
|
||||
assert session.root_mapper_path == "/dev/sda2"
|
||||
assert fake_runner.find("cryptsetup") == []
|
||||
|
||||
|
||||
def test_destructor_unmounts_everything_despite_failures(tmp_path, fake_runner):
|
||||
session = ImageSession(Device("sda"))
|
||||
session.working_folder = tmp_path
|
||||
session.boot_mount_path = tmp_path / "boot"
|
||||
session.root_mount_path = tmp_path / "root"
|
||||
fake_runner.failures.add("umount") # every umount fails; cleanup must go on
|
||||
|
||||
session.destructor()
|
||||
|
||||
umounted = [cmd[-1] for cmd in fake_runner.find("umount")]
|
||||
root = str(session.root_mount_path)
|
||||
# chroot binds first (deepest path first), then the partitions
|
||||
assert umounted == [
|
||||
f"{root}/dev/pts",
|
||||
f"{root}/dev",
|
||||
f"{root}/proc",
|
||||
f"{root}/sys",
|
||||
f"{root}/boot",
|
||||
root,
|
||||
str(session.boot_mount_path),
|
||||
]
|
||||
removed = [cmd[-1] for cmd in fake_runner.find("rmdir")]
|
||||
assert removed == [root, str(session.boot_mount_path), str(tmp_path)]
|
||||
|
||||
|
||||
def test_destructor_closes_luks_mapper(tmp_path, fake_runner):
|
||||
fake_runner.outputs["-s TYPE"] = "crypto_LUKS"
|
||||
fake_runner.outputs["-s UUID"] = "uuid-1"
|
||||
session = ImageSession(Device("sda"))
|
||||
session.decrypt_root()
|
||||
session.destructor()
|
||||
assert len(fake_runner.find("luksClose", "linux-image-manager-uuid-1")) == 1
|
||||
143
tests/unit/test_image_setup.py
Normal file
143
tests/unit/test_image_setup.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import pytest
|
||||
|
||||
from lim.errors import LimError
|
||||
from lim.image import choosers, raspberry, transfer
|
||||
from lim.image.plan import ImagePlan
|
||||
from lim.image.session import install_packages
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan():
|
||||
return ImagePlan()
|
||||
|
||||
|
||||
class TestDistributionChoosers:
|
||||
def test_arch_rpi4_uses_aarch64_and_high_memory_cost(self, plan, answers):
|
||||
answers("4")
|
||||
choosers.choose_arch(plan)
|
||||
assert plan.image_name == "ArchLinuxARM-rpi-aarch64-latest.tar.gz"
|
||||
assert plan.luks_memory_cost == "256000"
|
||||
assert plan.raspberry_pi_version == "4"
|
||||
assert plan.download_url == (
|
||||
"http://os.archlinuxarm.org/os/ArchLinuxARM-rpi-aarch64-latest.tar.gz"
|
||||
)
|
||||
|
||||
def test_arch_rpi1_uses_armv7_and_low_memory_cost(self, plan, answers):
|
||||
answers("1")
|
||||
choosers.choose_arch(plan)
|
||||
assert plan.image_name == "ArchLinuxARM-rpi-armv7-latest.tar.gz"
|
||||
assert plan.luks_memory_cost == "64000"
|
||||
|
||||
def test_arch_unknown_version_raises(self, plan, answers):
|
||||
answers("99")
|
||||
with pytest.raises(LimError):
|
||||
choosers.choose_arch(plan)
|
||||
|
||||
def test_manjaro_gnome_25(self, plan, answers):
|
||||
answers("gnome", "25")
|
||||
choosers.choose_manjaro(plan)
|
||||
assert plan.image_name == "manjaro-gnome-25.0.10-251013-linux612.iso"
|
||||
assert plan.base_download_url == "https://download.manjaro.org/gnome/25.0.10/"
|
||||
assert plan.image_checksum is None
|
||||
|
||||
def test_manjaro_raspberrypi_release_sets_rpi_version(self, plan, answers):
|
||||
answers("gnome", "raspberrypi")
|
||||
choosers.choose_manjaro(plan)
|
||||
assert plan.raspberry_pi_version == "4"
|
||||
assert plan.luks_memory_cost == "256000"
|
||||
assert plan.image_name == "Manjaro-ARM-gnome-rpi4-23.02.img.xz"
|
||||
|
||||
def test_manjaro_unknown_flavour_raises(self, plan, answers):
|
||||
answers("kde")
|
||||
with pytest.raises(LimError):
|
||||
choosers.choose_manjaro(plan)
|
||||
|
||||
def test_retropie_rpi3_shares_rpi2_image(self, plan, answers):
|
||||
answers("3")
|
||||
choosers.choose_retropie(plan)
|
||||
assert plan.image_name == "retropie-buster-4.8-rpi2_3_zero2w.img.gz"
|
||||
assert plan.image_checksum == "224e64d8820fc64046ba3850f481c87e"
|
||||
|
||||
def test_unknown_distribution_raises(self, plan, answers):
|
||||
answers("gentoo")
|
||||
with pytest.raises(LimError):
|
||||
choosers.choose_linux_image(plan)
|
||||
|
||||
|
||||
class TestPartitionInput:
|
||||
def test_contains_boot_size_and_writes_table(self):
|
||||
script = transfer.arch_partition_input("+500M")
|
||||
assert script.startswith("o\n")
|
||||
assert "\n+500M\n" in script
|
||||
assert script.endswith("w\n")
|
||||
|
||||
|
||||
class TestDecompressCommand:
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("image.zip", ["unzip", "-p"]),
|
||||
("image.img.gz", ["gunzip", "-c"]),
|
||||
("image.iso", ["pv"]),
|
||||
("image.img.xz", ["unxz", "-c"]),
|
||||
],
|
||||
)
|
||||
def test_known_formats(self, tmp_path, name, expected):
|
||||
path = tmp_path / name
|
||||
command = transfer.decompress_command(path)
|
||||
assert command[: len(expected)] == expected
|
||||
assert command[-1] == str(path)
|
||||
|
||||
def test_unknown_format_raises(self, tmp_path):
|
||||
with pytest.raises(LimError):
|
||||
transfer.decompress_command(tmp_path / "image.rar")
|
||||
|
||||
|
||||
class TestInstallPackages:
|
||||
def test_pacman_for_arch(self, tmp_path, fake_runner):
|
||||
install_packages("arch", tmp_path, "btrfs-progs")
|
||||
chroot_calls = [
|
||||
input_text
|
||||
for kind, cmd, input_text in fake_runner.calls
|
||||
if kind == "run" and cmd[0] == "chroot"
|
||||
]
|
||||
assert chroot_calls == ["pacman --noconfirm -S --needed btrfs-progs"]
|
||||
|
||||
def test_apt_for_retropie(self, tmp_path, fake_runner):
|
||||
install_packages("retropie", tmp_path, "btrfs-progs")
|
||||
chroot_calls = [
|
||||
input_text
|
||||
for kind, cmd, input_text in fake_runner.calls
|
||||
if kind == "run" and cmd[0] == "chroot"
|
||||
]
|
||||
assert chroot_calls == ["yes | apt install btrfs-progs"]
|
||||
|
||||
def test_unsupported_distribution_raises(self, tmp_path, fake_runner):
|
||||
with pytest.raises(LimError):
|
||||
install_packages("gentoo", tmp_path, "btrfs-progs")
|
||||
|
||||
|
||||
class TestConfigureHelpers:
|
||||
def test_configure_sudoers(self, tmp_path):
|
||||
raspberry.configure_sudoers(tmp_path, "administrator")
|
||||
sudoers = tmp_path / "etc/sudoers.d/administrator"
|
||||
assert sudoers.read_text() == "administrator ALL=(ALL:ALL) ALL\n"
|
||||
assert (sudoers.stat().st_mode & 0o777) == 0o440
|
||||
|
||||
def test_configure_ssh_key(self, tmp_path):
|
||||
public_key = tmp_path / "id_rsa.pub"
|
||||
public_key.write_text("ssh-rsa AAAA test@host\n")
|
||||
ssh_folder = tmp_path / "root/home/user/.ssh"
|
||||
authorized_keys = ssh_folder / "authorized_keys"
|
||||
raspberry.configure_ssh_key(str(public_key), ssh_folder, authorized_keys)
|
||||
assert authorized_keys.read_text() == "ssh-rsa AAAA test@host\n"
|
||||
assert (ssh_folder.stat().st_mode & 0o777) == 0o700
|
||||
assert (authorized_keys.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
def test_configure_ssh_key_missing_source_raises(self, tmp_path):
|
||||
with pytest.raises(LimError):
|
||||
raspberry.configure_ssh_key(
|
||||
str(tmp_path / "missing.pub"),
|
||||
tmp_path / ".ssh",
|
||||
tmp_path / ".ssh/authorized_keys",
|
||||
)
|
||||
59
tests/unit/test_luks.py
Normal file
59
tests/unit/test_luks.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from lim import luks
|
||||
from lim.errors import LimError
|
||||
|
||||
LUKS_DUMP = """\
|
||||
LUKS header information
|
||||
Version: 2
|
||||
UUID: 1234-abcd-5678
|
||||
"""
|
||||
|
||||
|
||||
def test_luks_uuid_parsed_from_dump(fake_runner):
|
||||
fake_runner.outputs["luksDump"] = LUKS_DUMP
|
||||
assert luks.luks_uuid("/dev/sda1") == "1234-abcd-5678"
|
||||
|
||||
|
||||
def test_luks_uuid_missing_raises(fake_runner):
|
||||
fake_runner.outputs["luksDump"] = "no uuid here"
|
||||
with pytest.raises(LimError):
|
||||
luks.luks_uuid("/dev/sda1")
|
||||
|
||||
|
||||
def test_update_fstab_is_idempotent(tmp_path):
|
||||
fstab = tmp_path / "fstab"
|
||||
fstab.write_text("# existing\n")
|
||||
luks.update_fstab("/dev/mapper/x", "/media/x", fstab_path=fstab)
|
||||
luks.update_fstab("/dev/mapper/x", "/media/x", fstab_path=fstab)
|
||||
lines = fstab.read_text().splitlines()
|
||||
assert lines.count("/dev/mapper/x /media/x btrfs defaults 0 2") == 1
|
||||
|
||||
|
||||
def test_create_luks_key_and_update_crypttab(tmp_path, fake_runner):
|
||||
fake_runner.outputs["luksDump"] = LUKS_DUMP
|
||||
key_dir = tmp_path / "luks-keys"
|
||||
crypttab = tmp_path / "crypttab"
|
||||
|
||||
luks.create_luks_key_and_update_crypttab(
|
||||
"encrypteddrive-sda",
|
||||
"/dev/sda1",
|
||||
key_directory=key_dir,
|
||||
crypttab_path=crypttab,
|
||||
)
|
||||
|
||||
keyfile = key_dir / "encrypteddrive-sda.keyfile"
|
||||
assert len(fake_runner.find("dd", "if=/dev/urandom", f"of={keyfile}")) == 1
|
||||
assert len(fake_runner.find("cryptsetup", "luksAddKey", "/dev/sda1")) == 1
|
||||
assert len(fake_runner.find("cryptsetup", "luksOpen", f"--key-file={keyfile}")) == 1
|
||||
expected_entry = f"encrypteddrive-sda UUID=1234-abcd-5678 {keyfile} luks"
|
||||
assert expected_entry in crypttab.read_text().splitlines()
|
||||
|
||||
# A second run must not duplicate the crypttab entry.
|
||||
luks.create_luks_key_and_update_crypttab(
|
||||
"encrypteddrive-sda",
|
||||
"/dev/sda1",
|
||||
key_directory=key_dir,
|
||||
crypttab_path=crypttab,
|
||||
)
|
||||
assert crypttab.read_text().splitlines().count(expected_entry) == 1
|
||||
30
tests/unit/test_packages.py
Normal file
30
tests/unit/test_packages.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from lim import config, packages
|
||||
from lim.errors import LimError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def package_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "PACKAGE_PATH", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_strips_comments_and_blank_lines(package_dir):
|
||||
(package_dir / "general.txt").write_text(
|
||||
"# header comment\nnano\ntree# inline comment\n\nhtop\n"
|
||||
)
|
||||
assert packages.get_packages("general") == ["nano", "tree", "htop"]
|
||||
|
||||
|
||||
def test_multiple_collections_are_concatenated(package_dir):
|
||||
(package_dir / "a.txt").write_text("one\n")
|
||||
subdir = package_dir / "server"
|
||||
subdir.mkdir()
|
||||
(subdir / "luks.txt").write_text("two\nthree\n")
|
||||
assert packages.get_packages("a", "server/luks") == ["one", "two", "three"]
|
||||
|
||||
|
||||
def test_missing_collection_raises(package_dir):
|
||||
with pytest.raises(LimError):
|
||||
packages.get_packages("does-not-exist")
|
||||
72
tests/unit/test_raspberry.py
Normal file
72
tests/unit/test_raspberry.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
|
||||
from lim.device import Device
|
||||
from lim.image import raspberry
|
||||
from lim.image.session import ImageSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(tmp_path):
|
||||
session = ImageSession(Device("mmcblk0"))
|
||||
session.root_mount_path = tmp_path
|
||||
session.boot_partition_uuid = "BOOT-UUID"
|
||||
return session
|
||||
|
||||
|
||||
class TestSeedBootUuid:
|
||||
def test_replaces_mmcblk_reference(self, tmp_path, session):
|
||||
fstab = tmp_path / "etc/fstab"
|
||||
fstab.parent.mkdir()
|
||||
fstab.write_text("/dev/mmcblk0p1 /boot vfat defaults 0 0\n")
|
||||
raspberry._seed_boot_uuid(session)
|
||||
assert fstab.read_text() == "UUID=BOOT-UUID /boot vfat defaults 0 0\n"
|
||||
|
||||
def test_skips_partuuid_based_images(self, tmp_path, session):
|
||||
fstab = tmp_path / "etc/fstab"
|
||||
fstab.parent.mkdir()
|
||||
content = "PARTUUID=6c586e13-01 /boot vfat defaults 0 0\n"
|
||||
fstab.write_text(content)
|
||||
raspberry._seed_boot_uuid(session) # must not raise
|
||||
assert fstab.read_text() == content
|
||||
|
||||
|
||||
class TestEnsureImageMounted:
|
||||
def test_reads_uuids_when_boot_already_mounted(self, fake_runner):
|
||||
session = ImageSession(Device("sda"))
|
||||
fake_runner.outputs["mount"] = "/dev/sda1 on /tmp/x/boot type vfat (rw)"
|
||||
# Distinct per-partition values so a boot/root swap cannot pass.
|
||||
fake_runner.outputs["/dev/sda1 -s UUID"] = "boot-uuid"
|
||||
fake_runner.outputs["/dev/sda2 -s UUID"] = "root-uuid"
|
||||
fake_runner.outputs["-s TYPE"] = "crypto_LUKS"
|
||||
|
||||
raspberry._ensure_image_mounted(session)
|
||||
|
||||
assert session.boot_partition_uuid == "boot-uuid"
|
||||
assert session.root_partition_uuid == "root-uuid"
|
||||
assert session.root_mapper_name == "linux-image-manager-root-uuid"
|
||||
assert session.root_mapper_path == "/dev/mapper/linux-image-manager-root-uuid"
|
||||
|
||||
def test_plain_root_keeps_partition_as_mapper(self, fake_runner):
|
||||
session = ImageSession(Device("sda"))
|
||||
fake_runner.outputs["mount"] = "/dev/sda1 on /tmp/x/boot type vfat (rw)"
|
||||
fake_runner.outputs["-s UUID"] = "uuid-7"
|
||||
fake_runner.outputs["-s TYPE"] = "ext4"
|
||||
|
||||
raspberry._ensure_image_mounted(session)
|
||||
|
||||
assert session.root_mapper_name is None
|
||||
assert session.root_mapper_path == "/dev/sda2"
|
||||
|
||||
def test_mounts_when_nothing_is_mounted(self, fake_runner, tmp_path):
|
||||
session = ImageSession(Device("sda"))
|
||||
session.boot_mount_path = tmp_path / "boot"
|
||||
session.root_mount_path = tmp_path / "root"
|
||||
fake_runner.outputs["-s TYPE"] = "ext4"
|
||||
fake_runner.outputs["/dev/sda1 -s UUID"] = "boot-uuid"
|
||||
fake_runner.outputs["/dev/sda2 -s UUID"] = "root-uuid"
|
||||
|
||||
raspberry._ensure_image_mounted(session)
|
||||
|
||||
assert len(fake_runner.find("mount", "-v")) == 2
|
||||
assert session.boot_partition_uuid == "boot-uuid"
|
||||
assert session.root_partition_uuid == "root-uuid"
|
||||
92
tests/unit/test_runner.py
Normal file
92
tests/unit/test_runner.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Tests against the real runner module (no fake) using harmless commands."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from lim import runner, ui
|
||||
from lim.errors import LimError
|
||||
|
||||
MISSING = "lim-definitely-missing-binary-x"
|
||||
|
||||
|
||||
class TestMissingBinaries:
|
||||
def test_run_raises_lim_error(self):
|
||||
with pytest.raises(LimError, match="Command not found"):
|
||||
runner.run([MISSING])
|
||||
|
||||
def test_run_tolerates_when_check_is_false(self):
|
||||
result = runner.run([MISSING], check=False)
|
||||
assert result.returncode == runner.COMMAND_NOT_FOUND
|
||||
|
||||
def test_output_raises_lim_error(self):
|
||||
with pytest.raises(LimError, match="Command not found"):
|
||||
runner.output([MISSING])
|
||||
|
||||
def test_output_tolerates_when_check_is_false(self):
|
||||
assert runner.output([MISSING], check=False) == ""
|
||||
|
||||
def test_succeeds_returns_false(self):
|
||||
assert runner.succeeds([MISSING]) is False
|
||||
|
||||
def test_tolerated_paths_emit_a_warning(self, monkeypatch):
|
||||
warnings = []
|
||||
monkeypatch.setattr(ui, "warning", warnings.append)
|
||||
runner.run([MISSING], check=False)
|
||||
runner.output([MISSING], check=False)
|
||||
runner.succeeds([MISSING])
|
||||
assert len(warnings) == 3
|
||||
assert all("Command not found" in text for text in warnings)
|
||||
|
||||
def test_pipeline_raises_lim_error(self):
|
||||
with pytest.raises(LimError, match="Command not found"):
|
||||
runner.pipeline(["echo", "x"], [MISSING])
|
||||
|
||||
def test_pipeline_raises_when_first_member_is_missing(self):
|
||||
with pytest.raises(LimError, match=f"Command not found: {MISSING}"):
|
||||
runner.pipeline([MISSING], ["cat"])
|
||||
|
||||
def test_pipeline_reaps_started_members(self, monkeypatch):
|
||||
spawned = []
|
||||
original_popen = subprocess.Popen
|
||||
|
||||
def spying_popen(*args, **kwargs):
|
||||
process = original_popen(*args, **kwargs)
|
||||
spawned.append(process)
|
||||
return process
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", spying_popen)
|
||||
with pytest.raises(LimError, match="Command not found"):
|
||||
runner.pipeline(["sleep", "60"], [MISSING])
|
||||
assert len(spawned) == 1
|
||||
assert spawned[0].poll() is not None # killed and reaped, not running
|
||||
assert spawned[0].stdout.closed
|
||||
|
||||
def test_run_prefers_explicit_error_message(self):
|
||||
with pytest.raises(LimError, match="custom message"):
|
||||
runner.run([MISSING], error_msg="custom message")
|
||||
|
||||
def test_output_prefers_explicit_error_message(self):
|
||||
with pytest.raises(LimError, match="custom message"):
|
||||
runner.output([MISSING], error_msg="custom message")
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def test_run_returns_completed_process(self):
|
||||
assert runner.run(["true"]).returncode == 0
|
||||
|
||||
def test_run_raises_on_nonzero_exit(self):
|
||||
with pytest.raises(LimError, match="Command failed with code 1"):
|
||||
runner.run(["false"])
|
||||
|
||||
def test_output_returns_stripped_stdout(self):
|
||||
assert runner.output(["echo", "hello"]) == "hello"
|
||||
|
||||
def test_succeeds_reflects_exit_code(self):
|
||||
assert runner.succeeds(["true"]) is True
|
||||
assert runner.succeeds(["false"]) is False
|
||||
|
||||
def test_pipeline_runs_and_checks_every_member(self):
|
||||
runner.pipeline(["echo", "x"], ["cat"]) # must not raise
|
||||
with pytest.raises(LimError, match="Pipeline failed"):
|
||||
runner.pipeline(["false"], ["cat"])
|
||||
70
tests/unit/test_storage.py
Normal file
70
tests/unit/test_storage.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import pytest
|
||||
|
||||
from lim import device
|
||||
from lim.device import Device
|
||||
from lim.storage import raid1, single_drive
|
||||
from lim.storage.common import StorageTarget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def block_devices(monkeypatch):
|
||||
monkeypatch.setattr(device, "is_block_device", lambda path: True)
|
||||
monkeypatch.setattr(Device, "optimal_blocksize", property(lambda self: "4K"))
|
||||
|
||||
|
||||
def test_storage_target_derives_all_paths():
|
||||
target = StorageTarget(Device("sdb"))
|
||||
assert target.mapper_name == "encrypteddrive-sdb"
|
||||
assert target.mapper_path == "/dev/mapper/encrypteddrive-sdb"
|
||||
assert target.mount_path == "/media/encrypteddrive-sdb"
|
||||
assert target.partition_path == "/dev/sdb1"
|
||||
|
||||
|
||||
def test_single_drive_setup_command_sequence(
|
||||
fake_runner, answers, block_devices, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("lim.system.real_user", lambda: "kevin")
|
||||
answers("sdb", "N") # device name, skip overwrite
|
||||
|
||||
single_drive.setup()
|
||||
|
||||
fdisk_calls = [
|
||||
(cmd, input_text)
|
||||
for kind, cmd, input_text in fake_runner.calls
|
||||
if kind == "run" and cmd[0] == "fdisk"
|
||||
]
|
||||
assert [input_text for _, input_text in fdisk_calls] == [
|
||||
single_drive.CREATE_GPT_TABLE_INPUT,
|
||||
single_drive.CREATE_PARTITION_INPUT,
|
||||
]
|
||||
assert len(fake_runner.find("cryptsetup", "-y", "luksFormat", "/dev/sdb1")) == 1
|
||||
assert len(fake_runner.find("cryptsetup", "luksOpen", "encrypteddrive-sdb")) == 1
|
||||
assert len(fake_runner.find("mkfs.btrfs", "/dev/mapper/encrypteddrive-sdb")) == 1
|
||||
assert len(fake_runner.find("mount", "/media/encrypteddrive-sdb")) == 1
|
||||
assert len(fake_runner.find("chown", "kevin:kevin")) == 1
|
||||
|
||||
|
||||
def test_single_drive_umount(fake_runner, answers, block_devices):
|
||||
answers("sdb")
|
||||
single_drive.umount()
|
||||
assert len(fake_runner.find("umount", "/dev/mapper/encrypteddrive-sdb")) == 1
|
||||
assert len(fake_runner.find("cryptsetup", "luksClose", "encrypteddrive-sdb")) == 1
|
||||
|
||||
|
||||
def test_raid1_setup_uses_both_whole_devices(fake_runner, answers, block_devices):
|
||||
answers("sdb", "sdc")
|
||||
raid1.setup()
|
||||
assert len(fake_runner.find("cryptsetup", "luksFormat", "/dev/sdb")) == 1
|
||||
assert len(fake_runner.find("cryptsetup", "luksFormat", "/dev/sdc")) == 1
|
||||
mkfs = fake_runner.find("mkfs.btrfs", "-m raid1", "-d raid1")
|
||||
assert mkfs == [
|
||||
[
|
||||
"mkfs.btrfs",
|
||||
"-m",
|
||||
"raid1",
|
||||
"-d",
|
||||
"raid1",
|
||||
"/dev/mapper/encrypteddrive-sdb",
|
||||
"/dev/mapper/encrypteddrive-sdc",
|
||||
]
|
||||
]
|
||||
119
tests/unit/test_sync.py
Normal file
119
tests/unit/test_sync.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lim import config, system
|
||||
from lim.data import sync
|
||||
from lim.errors import LimError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home(tmp_path):
|
||||
home = tmp_path / "home/kevin"
|
||||
(home / ".ssh").mkdir(parents=True)
|
||||
(home / ".ssh/id_rsa").write_text("key")
|
||||
(home / ".gitconfig").write_text("[user]")
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_path(tmp_path):
|
||||
return tmp_path / "decrypted/data"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backup_folder(tmp_path):
|
||||
return tmp_path / "decrypted/backup/import/20260707000000"
|
||||
|
||||
|
||||
def test_import_plan_only_covers_existing_items(home, data_path, backup_folder):
|
||||
operations = sync.build_sync_plan("import", home, data_path, backup_folder)
|
||||
sources = [operation.source for operation in operations]
|
||||
assert sources == [f"{home}/.ssh/", f"{home}/.gitconfig"]
|
||||
for operation in operations:
|
||||
assert operation.destination.startswith(str(data_path))
|
||||
|
||||
|
||||
def test_export_swaps_source_and_destination(home, data_path, backup_folder):
|
||||
exported = Path(f"{data_path}{home}/.gitconfig")
|
||||
exported.parent.mkdir(parents=True)
|
||||
exported.write_text("[user]")
|
||||
operations = sync.build_sync_plan("export", home, data_path, backup_folder)
|
||||
assert operations == [
|
||||
sync.SyncOperation(
|
||||
source=str(exported),
|
||||
destination=f"{home}/.gitconfig",
|
||||
backup_dir=str(Path(f"{backup_folder}{home}")),
|
||||
is_directory=False,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_unknown_mode_raises(home, data_path, backup_folder):
|
||||
with pytest.raises(LimError):
|
||||
sync.build_sync_plan("sideways", home, data_path, backup_folder)
|
||||
|
||||
|
||||
def test_rsync_command_uses_delete_only_for_directories():
|
||||
directory_op = sync.SyncOperation("src/", "dst/", "backup", is_directory=True)
|
||||
file_op = sync.SyncOperation("src", "dst", "backup", is_directory=False)
|
||||
assert sync.rsync_command(directory_op) == [
|
||||
"rsync", "-abcEPuvW", "--delete", "--backup-dir=backup", "src/", "dst/"
|
||||
]
|
||||
assert sync.rsync_command(file_op) == [
|
||||
"rsync", "-abcEPuvW", "--backup-dir=backup", "src", "dst"
|
||||
]
|
||||
|
||||
|
||||
def test_import_syncs_from_real_home(tmp_path, fake_runner, monkeypatch):
|
||||
real_home = tmp_path / "home/kevin"
|
||||
real_home.mkdir(parents=True)
|
||||
(real_home / ".gitconfig").write_text("[user]")
|
||||
monkeypatch.setattr(system, "real_home", lambda: real_home)
|
||||
monkeypatch.setattr(config, "DECRYPTED_PATH", tmp_path / "decrypted")
|
||||
monkeypatch.setattr(config, "DATA_PATH", tmp_path / "decrypted/data")
|
||||
monkeypatch.setattr(config, "BACKUP_PATH", tmp_path / "decrypted/backup")
|
||||
fake_runner.outputs["mount"] = f"encfs on {tmp_path / 'decrypted'} type fuse"
|
||||
|
||||
sync.import_from_system()
|
||||
|
||||
rsync_calls = fake_runner.find("rsync")
|
||||
assert len(rsync_calls) == 1
|
||||
assert rsync_calls[0][-2] == str(real_home / ".gitconfig")
|
||||
|
||||
|
||||
def test_export_chowns_real_home_without_sudo(tmp_path, fake_runner, monkeypatch):
|
||||
real_home = tmp_path / "home/kevin"
|
||||
(real_home / ".ssh").mkdir(parents=True)
|
||||
(real_home / ".ssh/id_rsa").write_text("key")
|
||||
monkeypatch.setattr(system, "real_home", lambda: real_home)
|
||||
monkeypatch.setattr(system, "real_user", lambda: "kevin")
|
||||
monkeypatch.setattr(config, "DECRYPTED_PATH", tmp_path / "decrypted")
|
||||
monkeypatch.setattr(config, "DATA_PATH", tmp_path / "decrypted/data")
|
||||
monkeypatch.setattr(config, "BACKUP_PATH", tmp_path / "decrypted/backup")
|
||||
fake_runner.outputs["mount"] = f"encfs on {tmp_path / 'decrypted'} type fuse"
|
||||
|
||||
sync.export_to_system()
|
||||
|
||||
chown_calls = [
|
||||
(cmd, sudo) for cmd, sudo in fake_runner.sudo_log if cmd[0] == "chown"
|
||||
]
|
||||
assert chown_calls == [
|
||||
(["chown", "-R", "kevin:kevin", str(real_home)], False)
|
||||
]
|
||||
assert (real_home / ".ssh/id_rsa").stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_execute_sync_plan_creates_folders_and_runs_rsync(
|
||||
tmp_path, fake_runner
|
||||
):
|
||||
operation = sync.SyncOperation(
|
||||
source=str(tmp_path / "src.txt"),
|
||||
destination=str(tmp_path / "deep/nested/dst.txt"),
|
||||
backup_dir=str(tmp_path / "backup/deep"),
|
||||
is_directory=False,
|
||||
)
|
||||
sync.execute_sync_plan([operation])
|
||||
assert (tmp_path / "deep/nested").is_dir()
|
||||
assert (tmp_path / "backup/deep").is_dir()
|
||||
assert len(fake_runner.find("rsync", "-abcEPuvW")) == 1
|
||||
52
tests/unit/test_verify.py
Normal file
52
tests/unit/test_verify.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from lim.errors import LimError
|
||||
from lim.image import verify
|
||||
|
||||
CONTENT = b"fake image content"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image(tmp_path):
|
||||
path = tmp_path / "image.img"
|
||||
path.write_bytes(CONTENT)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("algorithm", ["md5", "sha1", "sha256", "sha512"])
|
||||
def test_matching_checksum_passes(image, algorithm):
|
||||
checksum = hashlib.new(algorithm, CONTENT).hexdigest()
|
||||
verify.verify_checksum(image, checksum)
|
||||
|
||||
|
||||
def test_uppercase_checksum_passes(image):
|
||||
checksum = hashlib.sha256(CONTENT).hexdigest().upper()
|
||||
verify.verify_checksum(image, checksum)
|
||||
|
||||
|
||||
def test_wrong_checksum_raises(image):
|
||||
checksum = hashlib.sha256(b"other content").hexdigest()
|
||||
with pytest.raises(LimError):
|
||||
verify.verify_checksum(image, checksum)
|
||||
|
||||
|
||||
def test_unrecognized_digest_length_raises(image):
|
||||
with pytest.raises(LimError):
|
||||
verify.verify_checksum(image, "abc123")
|
||||
|
||||
|
||||
def test_missing_checksum_is_skipped(image):
|
||||
verify.verify_checksum(image, None) # must not raise
|
||||
|
||||
|
||||
def test_resolve_checksum_takes_first_available(fake_runner):
|
||||
fake_runner.success_by_fragment["image.img.sha1"] = False
|
||||
fake_runner.success_by_fragment["image.img.sha512"] = True
|
||||
fake_runner.outputs["-q -O -"] = "deadbeef image.img"
|
||||
assert verify.resolve_checksum("https://example.org/image.img") == "deadbeef"
|
||||
|
||||
|
||||
def test_resolve_checksum_returns_none_without_sources(fake_runner):
|
||||
assert verify.resolve_checksum("https://example.org/image.img") is None
|
||||
Reference in New Issue
Block a user