Detached dashboard actions spawn runtime_command(PROJECT_ROOT, subcommand); the removed _dashboard_spawn_executable interpreter pick has no seam to patch.
98 lines
4.5 KiB
Python
98 lines
4.5 KiB
Python
"""Dashboard gateway lifecycle actions must elevate on a system-scope install.
|
|
|
|
Regression for #110820: the Restart Gateway button spawned ``hermes gateway restart`` as the
|
|
dashboard's own user, and the CLI refuses system-scope verbs below root, so the button could
|
|
never work on a systemd *system* install — the refusal only reached the action log while the
|
|
endpoint reported a started action.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli._launchers import runtime_command
|
|
|
|
|
|
@pytest.fixture
|
|
def unprivileged(monkeypatch):
|
|
"""Run the predicate as a non-root user (CI containers can be root)."""
|
|
monkeypatch.setattr("os.geteuid", lambda: 1000)
|
|
|
|
|
|
@pytest.fixture
|
|
def system_scope_install(monkeypatch, tmp_path):
|
|
"""Only the SYSTEM unit is installed, the shape that needs root."""
|
|
system_unit = tmp_path / "etc" / "hermes-gateway.service"
|
|
system_unit.parent.mkdir(parents=True)
|
|
system_unit.write_text("[Service]\n", encoding="utf-8")
|
|
user_unit = tmp_path / "user" / "hermes-gateway.service"
|
|
monkeypatch.setattr(
|
|
"hermes_cli.gateway.get_systemd_unit_path",
|
|
lambda system=False: system_unit if system else user_unit,
|
|
)
|
|
return system_unit, user_unit
|
|
|
|
|
|
def _spawn(tmp_path, subcommand, *, sudo_ok: bool, targeted_only: bool = False, probes: list | None = None):
|
|
"""Run ``_spawn_hermes_action(subcommand)`` with Popen (and the ``sudo -n`` probes) faked;
|
|
the elevation decision runs through the production helpers. Returns the attempted argv.
|
|
``targeted_only`` models a command-scoped NOPASSWD sudoers entry: the blanket ``sudo -n true``
|
|
is refused but anything else under ``sudo -n`` is allowed."""
|
|
from hermes_cli import web_server_gateway
|
|
|
|
def run(argv, **_kwargs):
|
|
if probes is not None:
|
|
probes.append(list(argv))
|
|
refused = not sudo_ok or (targeted_only and argv[:3] == ["sudo", "-n", "true"])
|
|
return subprocess.CompletedProcess(argv, 1 if refused else 0)
|
|
|
|
child = MagicMock(spec=subprocess.Popen, pid=4242) # spec'd before Popen is patched
|
|
web_server_gateway._ACTION_LOG_FILES.setdefault("probe", "probe.log")
|
|
with patch.object(web_server_gateway, "_ACTION_LOG_DIR", tmp_path / "logs"), patch.object(
|
|
web_server_gateway.subprocess, "run", side_effect=run
|
|
), patch.object(web_server_gateway.subprocess, "Popen", return_value=child) as popen, patch(
|
|
"hermes_cli.web_server.PROJECT_ROOT", tmp_path
|
|
):
|
|
web_server_gateway._spawn_hermes_action(subcommand, "probe")
|
|
return popen.call_args.args[0]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"subcommand, both_units, elevated",
|
|
[
|
|
(["gateway", "restart"], False, True),
|
|
(["-p", "default", "gateway", "start"], False, True),
|
|
(["gateway", "status"], False, False), # no root gate on status
|
|
(["gateway", "restart"], True, False), # both units installed -> the CLI picks user scope
|
|
],
|
|
)
|
|
def test_only_system_scope_lifecycle_verbs_are_spawned_under_sudo(
|
|
unprivileged, system_scope_install, monkeypatch, tmp_path, subcommand, both_units, elevated
|
|
):
|
|
_system_unit, user_unit = system_scope_install
|
|
if both_units:
|
|
user_unit.parent.mkdir(parents=True)
|
|
user_unit.write_text("[Service]\n", encoding="utf-8")
|
|
monkeypatch.setattr("hermes_cli.web_server_profiles._resolve_profile_dir", lambda name: tmp_path / name)
|
|
argv = _spawn(tmp_path, subcommand, sudo_ok=True)
|
|
plain = runtime_command(tmp_path, subcommand)
|
|
assert argv == (["sudo", "-n", *plain] if elevated else plain)
|
|
|
|
|
|
def test_restart_without_passwordless_sudo_fails_the_request(unprivileged, system_scope_install, tmp_path):
|
|
# The endpoint reports Popen success; a child that can only refuse must fail the REQUEST instead.
|
|
with pytest.raises(RuntimeError, match="passwordless sudo is unavailable"):
|
|
_spawn(tmp_path, ["gateway", "restart"], sudo_ok=False)
|
|
|
|
|
|
def test_targeted_nopasswd_sudoers_still_elevates(unprivileged, system_scope_install, tmp_path):
|
|
# Same posture as the ``hermes update`` fleet restart: a refused blanket ``sudo -n true`` is
|
|
# inconclusive; the exact argv is then checked non-destructively (``sudo -n -l -- <argv>``).
|
|
probes: list = []
|
|
argv = _spawn(tmp_path, ["gateway", "restart"], sudo_ok=True, targeted_only=True, probes=probes)
|
|
plain = runtime_command(tmp_path, ["gateway", "restart"])
|
|
assert argv == ["sudo", "-n", *plain]
|
|
assert probes == [["sudo", "-n", "true"], ["sudo", "-n", "-l", "--", *plain]]
|