"""Tests for the allow_remote_command gate on CMD packets in hbc.""" import asyncio import subprocess import pytest from hbd.client import main as client_main from hbd.client.config import CLIENT_DEFAULTS, load_config class FakeConn: """Stand-in for AsyncConnection that records what the client sends back.""" def __init__(self): self.sent = [] async def sendto(self, msg, msg_id="HTB"): self.sent.append(msg) @pytest.fixture def conn(monkeypatch): """A fake connection with remote commands disabled and subprocess booby-trapped.""" monkeypatch.setattr(client_main, "allow_remote_command", False) def explode(*a, **kw): raise AssertionError("subprocess must not run when the command is refused") monkeypatch.setattr(subprocess, "check_output", explode) return FakeConn() def test_default_is_false(): assert CLIENT_DEFAULTS["allow_remote_command"] is False assert load_config("/nonexistent/hbc.yaml")["allow_remote_command"] is False assert client_main.allow_remote_command is False def test_refused_when_disabled(conn): asyncio.run(client_main.handle_command(conn, {"cmd": "id"})) assert conn.sent == [{ "service": "command", "msg": "Refused: allow_remote_command is false", }] def test_refusal_reported_for_every_attempt(conn): for _ in range(3): asyncio.run(client_main.handle_command(conn, {"cmd": "rm -rf /"})) assert len(conn.sent) == 3 def test_empty_command_is_ignored_either_way(conn, monkeypatch): asyncio.run(client_main.handle_command(conn, {})) monkeypatch.setattr(client_main, "allow_remote_command", True) asyncio.run(client_main.handle_command(conn, {"cmd": ""})) assert conn.sent == [] def test_executed_when_enabled(conn, monkeypatch): monkeypatch.setattr(client_main, "allow_remote_command", True) monkeypatch.setattr(subprocess, "check_output", lambda *a, **kw: b"uid=0(root)") asyncio.run(client_main.handle_command(conn, {"cmd": "id"})) assert conn.sent == [{"service": "command", "msg": "OK uid=0(root)"}] def test_failure_reported_when_enabled(conn, monkeypatch): monkeypatch.setattr(client_main, "allow_remote_command", True) def fail(*a, **kw): raise subprocess.CalledProcessError(1, "false") monkeypatch.setattr(subprocess, "check_output", fail) asyncio.run(client_main.handle_command(conn, {"cmd": "false"})) assert len(conn.sent) == 1 assert conn.sent[0]["msg"].startswith("CalledProcessError ") def test_config_file_can_opt_in(tmp_path): cfg_file = tmp_path / "hbc.yaml" cfg_file.write_text("allow_remote_command: true\n", encoding="utf-8") assert load_config(str(cfg_file))["allow_remote_command"] is True @pytest.mark.parametrize("configured, expected", [(True, True), (False, False), (None, False)]) def test_async_main_sets_the_flag_from_config(monkeypatch, configured, expected): """async_main must apply the config value before any CMD packet can arrive.""" monkeypatch.setattr(client_main, "allow_remote_command", not expected) class Bail(Exception): pass # gethostname() is the first call after the flag is assigned — bail there so the # test never opens a socket. def bail(): raise Bail monkeypatch.setattr(client_main.socket, "gethostname", bail) cfg = {} if configured is None else {"allow_remote_command": configured} with pytest.raises(Bail): asyncio.run(client_main.async_main(object(), cfg)) assert client_main.allow_remote_command is expected