Skip to content
Open
12 changes: 12 additions & 0 deletions tests/updater/test_components_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,15 @@ def test_origin_prefixed_branch_is_stripped(caplog):
bs = next(c for c in configs if c.name == "BlocksScreen")
assert bs.branch == "Release-2.0" # stray origin/ prefix stripped
assert any("origin/" in r.message for r in caplog.records)


def test_shipped_happy_hare_is_pinned_without_service():
with patch("updater.components.OVERRIDE_PATH", Path("/nonexistent/override.yaml")):
configs, _ = load_components()
hh = next(c for c in configs if c.name == "Happy-Hare")
assert hh.install_if_missing and hh.url
assert hh.version == "d5cce9f96991b270ff570b7497bbf6b4463a82b9"
assert (hh.branch, hh.reset_mode) == ("v3", "hard")
assert hh.service is None
names = [c.name for c in configs]
assert names.index("moonraker") < names.index("Happy-Hare")
10 changes: 10 additions & 0 deletions tests/updater/test_dbus_service_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,16 @@ async def test_followup_that_ran_sweeps_apt(self, svc):
svc._svc.background_apt_upgrade.assert_awaited_once()
assert svc._followed_up is True

@pytest.mark.asyncio
async def test_retrying_followup_emits_status_without_apt(self, svc):
svc._provisioned = True
svc._emit_status = AsyncMock()
svc._svc.run_followup = AsyncMock(return_value=(True, True))
await svc._followup_pass()
svc._emit_status.assert_awaited_once()
svc._svc.background_apt_upgrade.assert_not_called()
assert svc._followed_up is False

@pytest.mark.asyncio
async def test_followup_skips_apt_when_restart_pending(self, svc):
svc._provisioned = True
Expand Down
17 changes: 17 additions & 0 deletions tests/updater/test_executor_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,23 @@ async def test_run_hook_default_timeout(self, tmp_path, monkeypatch):
await run_hook("comp", tmp_path, "n", "p")
assert run.await_args.kwargs["timeout"] == 60.0

@pytest.mark.asyncio
async def test_extra_env_never_overrides_component_vars(
self, tmp_path, monkeypatch
):
import updater.executor as ex

monkeypatch.setattr(ex, "_HOOKS_DIR", tmp_path)
(tmp_path / "comp.sh").write_text("#!/bin/bash\nexit 0\n")
(tmp_path / "comp.sh").chmod(0o755)
with patch.object(ex, "_run", new=AsyncMock(return_value=(True, ""))) as run:
await run_hook(
"comp", tmp_path, "n", "p", extra_env={"X": "1", "NEW_HASH": "bad"}
)
env = run.await_args.kwargs["env"]
assert env["X"] == "1"
assert env["NEW_HASH"] == "n"

@pytest.mark.asyncio
async def test_hook_path_traversal_rejected(self, tmp_path, monkeypatch):
import updater.executor as ex
Expand Down
282 changes: 280 additions & 2 deletions tests/updater/test_hooks_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -70,15 +71,292 @@ def test_set_up_machine_rebuilds(self, tmp_path, repo):
assert not (tmp_path / "run" / "sentinel").exists()


UI_HOOK = HOOK.with_name("BlocksScreen.sh")
HH_HOOK = HOOK.parent / "Happy-Hare.sh"
_NOT_ROOT = pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file modes")
_MOONRAKER_CONF = (
"[server]\nhost: 0.0.0.0\n\n[mmu_server]\nenable_file_preprocessor: True\n"
)


def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args], capture_output=True, text=True, check=True
[
"git",
"-C",
str(repo),
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
*args,
],
capture_output=True,
text=True,
check=True,
).stdout.strip()


@pytest.fixture
def hh_home(tmp_path: Path) -> Path:
home = tmp_path / "home"
hh = home / "Happy-Hare"
(hh / "components").mkdir(parents=True)
(hh / "components" / "mmu_server.py").write_text("v1\n")
_git(hh, "init", "-q")
_git(hh, "add", ".")
_git(hh, "commit", "-qm", "v1")
components = home / "moonraker" / "moonraker" / "components"
components.mkdir(parents=True)
_git(home / "moonraker", "init", "-q")
conf = home / "printer_data" / "config" / "moonraker.conf"
conf.parent.mkdir(parents=True)
conf.write_text(_MOONRAKER_CONF)
return home


def _run_hh_hook(
tmp_path: Path,
home: Path,
prev: str = "",
new: str = "",
hook: Path = HH_HOOK,
**extra: str,
) -> subprocess.CompletedProcess:
"""Run a Happy-Hare/Moonraker hook with a stub sudo that records its argv."""
stub_bin = tmp_path / "stub-bin"
stub_bin.mkdir(exist_ok=True)
sudo = stub_bin / "sudo"
sudo.write_text(f'#!/bin/bash\necho "$*" >>{tmp_path}/sudo.log\n')
sudo.chmod(0o755)
env = {
"PATH": f"{stub_bin}:{os.environ['PATH']}",
"HOME": str(home),
"COMPONENT_PATH": str(home / "Happy-Hare"),
"PREV_HASH": prev,
"NEW_HASH": new,
**extra,
}
return subprocess.run(
["bash", str(hook)], env=env, capture_output=True, text=True, check=False
)


def _sudo_calls(tmp_path: Path) -> list[str]:
log = tmp_path / "sudo.log"
return log.read_text().splitlines() if log.exists() else []


def _exclude(home: Path) -> list[str]:
return (home / "moonraker" / ".git" / "info" / "exclude").read_text().splitlines()


class TestHappyHareHook:
def test_provision_links_excludes_and_restarts(self, tmp_path, hh_home):
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
assert dst.resolve() == hh_home / "Happy-Hare" / "components" / "mmu_server.py"
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
assert _git(hh_home / "moonraker", "status", "--porcelain") == ""
assert _sudo_calls(tmp_path) == [
"-n systemctl --no-block restart moonraker.service"
]

def test_restart_argv_matches_sudoers_rule(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
root = HOOK.parents[2]
rule = re.search(
r"NOPASSWD: /usr/bin/(systemctl --no-block restart) %s",
(root / "scripts" / "install-updater.sh").read_text(),
)
assert rule is not None
manifest = (root / "updater" / "components.yaml").read_text()
assert re.search(r"^\s*service: moonraker\.service$", manifest, re.M)
assert _sudo_calls(tmp_path) == [f"-n {rule[1]} moonraker.service"]

def test_pending_moonraker_restart_skips_restart(self, tmp_path, hh_home):
result = _run_hh_hook(
tmp_path,
hh_home,
BS_UPDATER_PENDING_RESTARTS="klipper.service moonraker.service",
)
assert result.returncode == 0, result.stderr
assert _sudo_calls(tmp_path) == []

def test_other_pending_restarts_still_restart(self, tmp_path, hh_home):
result = _run_hh_hook(
tmp_path, hh_home, BS_UPDATER_PENDING_RESTARTS="klipper.service"
)
assert result.returncode == 0, result.stderr
assert len(_sudo_calls(tmp_path)) == 1

def test_link_only_skips_restart(self, tmp_path, hh_home):
result = _run_hh_hook(tmp_path, hh_home, HH_LINK_ONLY="1")
assert result.returncode == 0, result.stderr
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()
assert _sudo_calls(tmp_path) == []

def test_printing_links_without_restart(self, tmp_path, hh_home):
result = _run_hh_hook(tmp_path, hh_home, BS_UPDATER_PRINTING="1")
assert result.returncode == 0, result.stderr
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()
assert _sudo_calls(tmp_path) == []

@_NOT_ROOT
def test_unwritable_exclude_warns_and_links(self, tmp_path, hh_home):
info = hh_home / "moonraker" / ".git" / "info"
info.mkdir(exist_ok=True)
(info / "exclude").write_text("*.pyc\n")
(info / "exclude").chmod(0o444)
try:
result = _run_hh_hook(tmp_path, hh_home)
finally:
(info / "exclude").chmod(0o644)
assert result.returncode == 0, result.stderr
assert "WARN" in result.stdout
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()

def test_unchanged_update_is_idempotent(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
head = _git(hh_home / "Happy-Hare", "rev-parse", "HEAD")
result = _run_hh_hook(tmp_path, hh_home, head, head)
assert result.returncode == 0, result.stderr
assert _exclude(hh_home).count("/moonraker/components/mmu_server.py") == 1
assert len(_sudo_calls(tmp_path)) == 1

def test_changed_mmu_server_restarts(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
hh = hh_home / "Happy-Hare"
prev = _git(hh, "rev-parse", "HEAD")
(hh / "components" / "mmu_server.py").write_text("v2\n")
_git(hh, "commit", "-qam", "v2")
result = _run_hh_hook(tmp_path, hh_home, prev, _git(hh, "rev-parse", "HEAD"))
assert result.returncode == 0, result.stderr
assert len(_sudo_calls(tmp_path)) == 2

def test_no_mmu_server_section_skips_restart(self, tmp_path, hh_home):
(hh_home / "printer_data" / "config" / "moonraker.conf").write_text(
"[server]\n"
)
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()
assert _sudo_calls(tmp_path) == []

def test_replaces_copied_file(self, tmp_path, hh_home):
dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
dst.write_text("old copy\n")
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert dst.is_symlink()

def test_exclude_without_trailing_newline(self, tmp_path, hh_home):
exclude = hh_home / "moonraker" / ".git" / "info" / "exclude"
exclude.write_text("*.pyc")
_run_hh_hook(tmp_path, hh_home)
assert _exclude(hh_home) == ["*.pyc", "/moonraker/components/mmu_server.py"]

def test_missing_moonraker_skips(self, tmp_path, hh_home):
subprocess.run(["rm", "-rf", str(hh_home / "moonraker")], check=True)
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert _sudo_calls(tmp_path) == []

def test_missing_source_fails(self, tmp_path, hh_home):
(hh_home / "Happy-Hare" / "components" / "mmu_server.py").unlink()
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 1


MR_HOOK = HOOK.with_name("moonraker.sh")
_EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"


class TestMoonrakerHook:
"""A Moonraker reclone must get the mmu_server link back."""

def _run(self, tmp_path: Path, home: Path, prev: str = "abc"):
return _run_hh_hook(
tmp_path,
home,
prev,
hook=MR_HOOK,
COMPONENT_PATH=str(home / "moonraker"),
)

def _dst(self, home: Path) -> Path:
return home / "moonraker" / "moonraker" / "components" / "mmu_server.py"

def test_update_relinks_without_restart(self, tmp_path, hh_home):
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
assert _sudo_calls(tmp_path) == []

def test_boot_reclone_relinks_and_restarts(self, tmp_path, hh_home):
result = self._run(tmp_path, hh_home, _EMPTY_TREE)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()
assert _sudo_calls(tmp_path) == [
"-n systemctl --no-block restart moonraker.service"
]

def test_intact_link_restores_exclude_without_restart(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
exclude = hh_home / "moonraker" / ".git" / "info" / "exclude"
exclude.write_text("")
result = self._run(tmp_path, hh_home, _EMPTY_TREE)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
assert len(_sudo_calls(tmp_path)) == 1

def test_no_happy_hare_skips(self, tmp_path, hh_home):
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert not self._dst(hh_home).exists()

def test_removes_link_left_by_failed_provision(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert not self._dst(hh_home).is_symlink()

def test_keeps_foreign_link_without_happy_hare(self, tmp_path, hh_home):
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
self._dst(hh_home).symlink_to(tmp_path / "elsewhere.py")
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()

@_NOT_ROOT
def test_relink_failure_never_fails_hook(self, tmp_path, hh_home):
components = self._dst(hh_home).parent
components.chmod(0o555)
try:
result = self._run(tmp_path, hh_home)
finally:
components.chmod(0o755)
assert result.returncode == 0, result.stderr
assert "WARN" in result.stdout


UI_HOOK = HOOK.with_name("BlocksScreen.sh")


def _commit(repo: Path, files: dict[str, str]) -> str:
for rel, text in files.items():
(repo / rel).parent.mkdir(parents=True, exist_ok=True)
Expand Down
Loading