Files
HaloKeymind/examples/host_cli_service/test_host_cli_service.py
T
mikecarper 1629725d7d Add safe ESP32 transports and Indicator UI
Reserve board-specific GPIOs, make ESP-NOW channel/format handling configurable, and coordinate WiFi, BLE, NTP, MQTT, and host actions safely.

Add the Indicator message page, exclusive transport selector, dynamic 480/320 rendering, larger transport typography, pairing cleanup, and corresponding tests and documentation.
2026-08-31 14:29:42 -07:00

1661 lines
61 KiB
Python

from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import socket
import stat
import struct
import subprocess
import tempfile
import threading
import unittest
from unittest import mock
import host_cli_service as service
import meshcore_clock_control as clock_helper
PUBLIC_KEY = "11" * 32
PRIVATE_KEY = "22" * 64
SIGNATURE = bytes([0xA5]) * 64
def request_payload(
text: str = "cpu-temp",
request_id: str = "89ABCDEF",
) -> tuple[str, bytes]:
encoded = base64.urlsafe_b64encode(text.encode()).rstrip(b"=").decode()
signed = f"HOSTCLI/1 REQUEST {request_id} 0123456789ABCDEF {encoded}"
message = "DEBUG " + signed + " " + SIGNATURE.hex().upper()
payload = json.dumps(
{"type": "DEBUG", "origin_id": PUBLIC_KEY, "message": message}
).encode()
return signed, payload
def claim_payload(
challenge: str = "FEDCBA9876543210",
request_id: str = "89ABCDEF",
) -> tuple[str, bytes]:
signed = (
f"HOSTCLI/1 CLAIMED {request_id} 0123456789ABCDEF {challenge}"
)
message = "DEBUG " + signed + " " + SIGNATURE.hex().upper()
payload = json.dumps(
{"type": "DEBUG", "origin_id": PUBLIC_KEY, "message": message}
).encode()
return signed, payload
class HostRequestTests(unittest.TestCase):
def test_accepts_only_a_valid_repeater_signature(self) -> None:
expected, payload = request_payload()
def verifier(signature: bytes, message: bytes, public: bytes) -> bool:
return (
signature == SIGNATURE
and message == expected.encode()
and public == bytes.fromhex(PUBLIC_KEY)
)
request = service.parse_and_verify_request(
payload, PUBLIC_KEY, verifier=verifier,
)
self.assertEqual(
service.HostRequest(
"89ABCDEF", "0123456789ABCDEF", "cpu-temp"
),
request,
)
def test_rejects_forged_content_and_wrong_origin(self) -> None:
_expected, payload = request_payload("cpu-temp")
forged = payload.replace(b"Y3B1LXRlbXA", b"ZXJhc2U")
with self.assertRaisesRegex(ValueError, "signature"):
service.parse_and_verify_request(
forged, PUBLIC_KEY, verifier=lambda *_args: False,
)
document = json.loads(payload)
document["origin_id"] = "33" * 32
with self.assertRaisesRegex(ValueError, "origin"):
service.parse_and_verify_request(
json.dumps(document), PUBLIC_KEY,
verifier=lambda *_args: True,
)
def test_ignores_unrelated_debug_records(self) -> None:
payload = json.dumps(
{"type": "DEBUG", "origin_id": PUBLIC_KEY,
"message": "DEBUG ordinary log"}
)
self.assertIsNone(
service.parse_and_verify_request(
payload, PUBLIC_KEY, verifier=lambda *_args: True,
)
)
def test_accepts_only_a_valid_signed_claim(self) -> None:
expected, payload = claim_payload()
def verifier(signature: bytes, message: bytes, public: bytes) -> bool:
return (
signature == SIGNATURE
and message == expected.encode()
and public == bytes.fromhex(PUBLIC_KEY)
)
self.assertEqual(
service.HostClaim(
"89ABCDEF", "0123456789ABCDEF", "FEDCBA9876543210"
),
service.parse_and_verify_claim(
payload, PUBLIC_KEY, verifier=verifier
),
)
with self.assertRaisesRegex(ValueError, "signature"):
service.parse_and_verify_claim(
payload, PUBLIC_KEY, verifier=lambda *_args: False
)
def test_rejects_a_zero_claim_challenge(self) -> None:
_signed, payload = claim_payload("0000000000000000")
with self.assertRaisesRegex(ValueError, "must not be zero"):
service.parse_and_verify_claim(
payload, PUBLIC_KEY, verifier=lambda *_args: True
)
class RequestHandlerTests(unittest.TestCase):
def test_reads_pi_cpu_temperature(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "temp"
path.write_text("47250\n", encoding="ascii")
self.assertEqual(
service.HostResult("CPU 47.2 C"),
service.handle_request("cpu-temp", path),
)
def test_never_executes_or_accepts_injected_commands(self) -> None:
response = service.handle_request(
"cpu-temp\r\nerase", Path("/does/not/matter")
)
self.assertEqual(
service.HostResult("Err - unsupported host request"), response
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "cpu-temp"
)
command = service.make_serial_reply(request, "OK\r\nerase")
self.assertEqual(
"host.reply 12345678 FEDCBA9876543210 OK erase", command
)
self.assertNotIn("\r", command)
self.assertNotIn("\n", command)
self.assertEqual(
"OK 31mnot-a-terminal-control",
service.bounded_line_text("OK\N{CONTROL SEQUENCE INTRODUCER}31m"
"not-a-terminal-control"),
)
def test_exact_fixed_commands_and_opt_in_recovery_actions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
uptime = root / "uptime"
load = root / "loadavg"
memory = root / "meminfo"
uptime.write_text("183845.2 0.0\n", encoding="ascii")
load.write_text("0.12 0.34 0.56 1/1 1\n", encoding="ascii")
memory.write_text(
"MemTotal: 1024000 kB\nMemAvailable: 512000 kB\n",
encoding="ascii",
)
kwargs = {
"uptime_path": uptime,
"load_path": load,
"memory_path": memory,
"disk_path": root,
}
self.assertEqual(
"Uptime 2d 3h 4m",
service.handle_request("uptime", root, **kwargs).text,
)
self.assertEqual(
"Load 0.12 0.34 0.56",
service.handle_request("load", root, **kwargs).text,
)
self.assertEqual(
"Memory 500/1000 MiB available",
service.handle_request("memory", root, **kwargs).text,
)
self.assertTrue(
service.handle_request("disk-free", root, **kwargs).text.startswith(
"Disk "
)
)
disabled = service.handle_request("reboot", Path("/unused"))
self.assertEqual("Err - host reboot is disabled", disabled.text)
enabled = service.handle_request(
"reboot", Path("/unused"), allow_reboot=True
)
self.assertEqual(service.HostResult("", host_action="reboot"), enabled)
self.assertEqual(
"Err - host network restart is disabled",
service.handle_request("network restart", Path("/unused")).text,
)
self.assertEqual(
service.HostResult("", host_action="network-restart"),
service.handle_request(
"network restart",
Path("/unused"),
allow_network_restart=True,
),
)
operation_id = "A1" * 16
self.assertEqual(
service.HostResult(
"", host_action="status", operation_id=operation_id
),
service.handle_request(
f"action status {operation_id}",
Path("/unused"),
allow_reboot=True,
),
)
self.assertEqual(
service.HostResult("Err - unsupported host request"),
service.handle_request("reboot now", Path("/unused"),
allow_reboot=True),
)
for injected in (
"network restart now",
"network restart\nreboot",
f"action status {operation_id};reboot",
f"action status {operation_id.lower()}",
):
with self.subTest(injected=injected):
self.assertIsNone(
service.handle_request(
injected,
Path("/unused"),
allow_reboot=True,
allow_network_restart=True,
).host_action
)
def test_clock_status_is_read_only_and_strictly_bounded(self) -> None:
status_result = mock.Mock(returncode=0, stdout="yes\n")
with mock.patch.object(service.time, "time", return_value=1788147000), \
mock.patch.object(
service.subprocess, "run", return_value=status_result
) as run:
status = service.handle_request("clock status", Path("/unused"))
self.assertEqual(
"Clock epoch 1788147000; NTP synchronized yes", status.text
)
self.assertEqual(
[
"/usr/bin/timedatectl", "show",
"--property=NTPSynchronized", "--value",
],
run.call_args.args[0],
)
self.assertFalse(run.call_args.kwargs["shell"])
self.assertEqual(
service.CLOCK_STATUS_CHILD_TIMEOUT_SECONDS,
run.call_args.kwargs["timeout"],
)
disabled = service.handle_request(
"clock set 1788147000", Path("/unused")
)
self.assertEqual("Err - host clock control is disabled", disabled.text)
disabled_sync = service.handle_request("clock sync", Path("/unused"))
self.assertEqual(
"Err - host clock control is disabled", disabled_sync.text
)
rejected = (
"clock set",
"clock set ",
"clock set +1788147000",
"clock set -1",
"clock set 01788147000",
"clock set 1788147000 extra",
"clock set 1788147000\nreboot",
"clock set 999999999999999999999999999999999999",
)
with mock.patch.object(service, "_exchange_clock_control") as exchange:
for request in rejected:
with self.subTest(request=request):
result = service.handle_request(
request, Path("/unused"), allow_clock_control=True
)
self.assertTrue(result.text.startswith("Err -"))
exchange.assert_not_called()
def test_clock_control_uses_only_the_fixed_unix_socket_protocol(self) -> None:
with mock.patch.object(
service,
"_exchange_clock_control",
return_value=(
"OK clock set to 1788147000; NTP sync requested"
),
) as exchange, mock.patch.object(service.subprocess, "run") as run:
result = service.handle_request(
"clock set 1788147000",
Path("/unused"),
allow_clock_control=True,
)
self.assertEqual(
"OK - clock set to 1788147000; NTP sync requested", result.text
)
exchange.assert_called_once_with("set 1788147000")
run.assert_not_called()
with mock.patch.object(
service,
"_exchange_clock_control",
return_value="OK NTP sync requested",
) as exchange, mock.patch.object(service.subprocess, "run") as run:
result = service.handle_request(
"clock sync", Path("/unused"), allow_clock_control=True
)
self.assertEqual("OK - NTP sync requested", result.text)
exchange.assert_called_once_with("sync")
run.assert_not_called()
def test_clock_status_uses_chrony_when_systemd_marker_is_wrong(self) -> None:
systemd = mock.Mock(returncode=0, stdout="no\n")
chrony = mock.Mock(
returncode=0,
stdout=(
"Stratum : 4\n"
"System time : 0.000000000 seconds fast of NTP time\n"
"Leap status : Normal\n"
),
)
with mock.patch.object(service.time, "time", return_value=1788147000), \
mock.patch.object(
service.subprocess, "run", side_effect=[systemd, chrony]
) as run, mock.patch.object(Path, "is_file", return_value=True), \
mock.patch.object(service.os, "access", return_value=True):
result = service.handle_request("clock status", Path("/unused"))
self.assertEqual(
"Clock epoch 1788147000; NTP synchronized yes (chrony)",
result.text,
)
self.assertEqual(
["/usr/bin/chronyc", "-n", "tracking"],
run.call_args.args[0],
)
self.assertEqual(1.5, run.call_args.kwargs["timeout"])
def test_clock_epoch_boundaries_in_both_layers(self) -> None:
for epoch in (service.MIN_CLOCK_EPOCH, service.MAX_CLOCK_EPOCH):
with self.subTest(epoch=epoch), mock.patch.object(
service,
"_exchange_clock_control",
return_value=f"OK clock set to {epoch}; NTP sync requested",
) as exchange:
result = service.handle_request(
f"clock set {epoch}",
Path("/unused"),
allow_clock_control=True,
)
self.assertEqual(
f"OK - clock set to {epoch}; NTP sync requested", result.text
)
exchange.assert_called_once_with(f"set {epoch}")
with mock.patch.object(service, "_exchange_clock_control") as exchange:
for epoch in (
service.MIN_CLOCK_EPOCH - 1,
service.MAX_CLOCK_EPOCH + 1,
):
with self.subTest(epoch=epoch):
result = service.handle_request(
f"clock set {epoch}",
Path("/unused"),
allow_clock_control=True,
)
self.assertIn("2020 through 2099", result.text)
exchange.assert_not_called()
for epoch in (
clock_helper.MIN_CLOCK_EPOCH,
clock_helper.MAX_CLOCK_EPOCH,
):
self.assertEqual(
("set", epoch),
clock_helper.parse_request(f"set {epoch}\n".encode()),
)
for epoch in (
clock_helper.MIN_CLOCK_EPOCH - 1,
clock_helper.MAX_CLOCK_EPOCH + 1,
):
with self.assertRaises(ValueError):
clock_helper.parse_request(f"set {epoch}\n".encode())
def test_clock_helper_timeouts_are_bounded_and_fail_closed(self) -> None:
self.assertLess(
2 * clock_helper.CHILD_TIMEOUT_SECONDS,
service.CLOCK_CONTROL_TIMEOUT_SECONDS,
)
self.assertLess(service.CLOCK_CONTROL_TIMEOUT_SECONDS, 6)
timeout = subprocess.TimeoutExpired(["timedatectl"], 1.5)
with mock.patch.object(
clock_helper.subprocess, "run", side_effect=timeout
) as run:
self.assertEqual(
"NTP enable request failed", clock_helper.request_ntp_sync()
)
self.assertEqual(1, run.call_count)
self.assertEqual(
clock_helper.CHILD_TIMEOUT_SECONDS,
run.call_args.kwargs["timeout"],
)
enabled = mock.Mock(returncode=0)
with mock.patch.object(
clock_helper.subprocess,
"run",
side_effect=[enabled, timeout],
) as run, mock.patch.object(
clock_helper.Path, "is_file", return_value=False
):
self.assertEqual(
"systemd-timesyncd restart failed",
clock_helper.request_ntp_sync(),
)
self.assertEqual(2, run.call_count)
self.assertEqual(
[
"/usr/bin/systemctl",
"restart",
"systemd-timesyncd.service",
],
run.call_args.args[0],
)
with mock.patch.object(
clock_helper.subprocess, "run", side_effect=FileNotFoundError
) as run:
self.assertEqual(
"NTP enable request failed", clock_helper.request_ntp_sync()
)
self.assertEqual(1, run.call_count)
def test_clock_set_never_disables_ntp_and_reports_partial_success(
self,
) -> None:
with mock.patch.object(clock_helper.time, "clock_settime") as settime, \
mock.patch.object(
clock_helper,
"request_ntp_sync",
return_value="chrony step request failed",
) as sync, \
mock.patch.object(clock_helper, "run_fixed") as run_fixed:
self.assertEqual(
(True, "chrony step request failed"),
clock_helper.set_clock(1788147000),
)
settime.assert_called_once_with(
clock_helper.time.CLOCK_REALTIME, 1788147000.0
)
sync.assert_called_once_with()
run_fixed.assert_not_called()
with mock.patch.object(
clock_helper,
"set_clock",
return_value=(True, clock_helper.CHRONY_STEP_ERROR),
):
self.assertEqual(
"PARTIAL clock set to 1788147000; NTP enabled; "
"chrony step request failed",
clock_helper.process_request(b"set 1788147000\n"),
)
self.assertEqual(
"Warning - clock set to 1788147000; NTP enabled; "
"chrony step request failed",
service._clock_control_result(
"PARTIAL clock set to 1788147000; NTP enabled; "
"chrony step request failed",
"set",
1788147000,
).text,
)
with mock.patch.object(
clock_helper.time, "clock_settime", side_effect=OSError
), mock.patch.object(clock_helper, "request_ntp_sync") as sync:
self.assertEqual(
(False, None), clock_helper.set_clock(1788147000)
)
sync.assert_not_called()
def test_reply_is_utf8_bounded(self) -> None:
text = "x" * 161 + "\N{EURO SIGN}"
bounded = service.bounded_line_text(text)
self.assertLessEqual(len(bounded.encode()), service.MAX_REPLY_BYTES)
self.assertEqual("x" * 161, bounded)
def test_runs_only_a_fixed_program_with_validated_arguments(self) -> None:
programs = {
"fan": service.ProgramDefinition(
alias="fan",
argv=("/usr/local/bin/mesh-fan-control", "--source", "lora"),
arguments=(
service.ProgramArgumentRule(
"state", "choice", choices=("on", "off")
),
service.ProgramArgumentRule(
"minutes", "integer", minimum=1, maximum=60
),
),
timeout_seconds=3,
)
}
completed = subprocess_result = mock.Mock(
returncode=0, stdout="fan scheduled\n"
)
with mock.patch.object(
service.subprocess, "run", return_value=completed
) as run:
result = service.handle_request(
"run fan on 15", Path("/unused"), programs=programs
)
self.assertEqual("fan scheduled", result.text)
self.assertIs(completed, subprocess_result)
argv = run.call_args.args[0]
self.assertEqual(
[
"/usr/local/bin/mesh-fan-control",
"--source",
"lora",
"on",
"15",
],
argv,
)
self.assertFalse(run.call_args.kwargs["shell"])
self.assertEqual("/", run.call_args.kwargs["cwd"])
def test_rejects_program_alias_and_argument_injection(self) -> None:
programs = {
"fan": service.ProgramDefinition(
alias="fan",
argv=("/usr/local/bin/mesh-fan-control",),
arguments=(
service.ProgramArgumentRule(
"state", "choice", choices=("on", "off")
),
),
)
}
rejected = (
"run other on",
"run fan --help",
"run fan on;reboot",
"run fan on\nreboot",
"run fan on extra",
"run fan -1",
)
with mock.patch.object(service.subprocess, "run") as run:
for request in rejected:
with self.subTest(request=request):
self.assertTrue(
service.handle_request(
request, Path("/unused"), programs=programs
).text.startswith("Err -")
)
run.assert_not_called()
def test_loads_a_bounded_program_allowlist(self) -> None:
executable = Path("/bin/echo").resolve()
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / "programs.json"
config.write_text(
json.dumps(
{
"programs": {
"sample": {
"argv": [str(executable), "fixed"],
"timeout_seconds": 2,
"arguments": [
{
"name": "mode",
"type": "choice",
"choices": ["short", "full"],
},
{
"name": "count",
"type": "integer",
"min": 1,
"max": 5,
},
{
"name": "label",
"type": "token",
"max_bytes": 12,
},
],
}
}
}
),
encoding="utf-8",
)
config.chmod(0o644)
programs = service.load_programs_file(config)
self.assertEqual((str(executable), "fixed"), programs["sample"].argv)
self.assertEqual(3, len(programs["sample"].arguments))
def test_rejects_writable_program_allowlist(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / "programs.json"
config.write_text('{"programs": {}}', encoding="ascii")
config.chmod(0o666)
with self.assertRaisesRegex(ValueError, "group/world writable"):
service.load_programs_file(config)
class ClockControlServiceTests(unittest.TestCase):
def test_daemon_protocol_rejects_injection_before_mutation(self) -> None:
rejected = (
b"",
b"sync",
b"sync\r\n",
b" sync\n",
b"sync \n",
b"sync\nset 1788147000\n",
b"set\n",
b"set +1788147000\n",
b"set -1788147000\n",
b"set 01788147000\n",
b"set 1788147000 extra\n",
b"set 1788147000\x00\n",
b"set 1788147000\nreboot",
b"set 999999999999999999999999\n",
b"\xff\n",
b"x" * (clock_helper.MAX_REQUEST_BYTES + 1),
)
with mock.patch.object(clock_helper, "set_clock") as set_clock, \
mock.patch.object(clock_helper, "request_ntp_sync") as sync:
for request in rejected:
with self.subTest(request=request):
with self.assertRaises(ValueError):
clock_helper.process_request(request)
set_clock.assert_not_called()
sync.assert_not_called()
self.assertEqual(
("sync", None), clock_helper.parse_request(b"sync\n")
)
self.assertEqual(
("set", 1788147000),
clock_helper.parse_request(b"set 1788147000\n"),
)
def test_daemon_reports_success_failure_and_partial_outcomes(self) -> None:
with mock.patch.object(
clock_helper, "request_ntp_sync", return_value=None
):
self.assertEqual(
"OK NTP sync requested",
clock_helper.process_request(b"sync\n"),
)
with mock.patch.object(
clock_helper,
"request_ntp_sync",
return_value=clock_helper.NTP_ENABLE_ERROR,
):
self.assertEqual(
"ERR NTP enable request failed",
clock_helper.process_request(b"sync\n"),
)
with mock.patch.object(
clock_helper,
"request_ntp_sync",
return_value=clock_helper.CHRONY_STEP_ERROR,
):
self.assertEqual(
"PARTIAL NTP enabled; chrony step request failed",
clock_helper.process_request(b"sync\n"),
)
outcomes = (
((False, None), "ERR clock set failed"),
(
(True, None),
"OK clock set to 1788147000; NTP sync requested",
),
(
(True, clock_helper.NTP_ENABLE_ERROR),
"PARTIAL clock set to 1788147000; "
"NTP enable request failed",
),
(
(True, clock_helper.TIMESYNCD_RESTART_ERROR),
"PARTIAL clock set to 1788147000; NTP enabled; "
"systemd-timesyncd restart failed",
),
)
for outcome, expected in outcomes:
with self.subTest(outcome=outcome), mock.patch.object(
clock_helper, "set_clock", return_value=outcome
):
self.assertEqual(
expected,
clock_helper.process_request(b"set 1788147000\n"),
)
def test_peer_credentials_reject_wrong_uid_or_primary_gid(self) -> None:
for credentials in ((123, 999, 456), (123, 123, 999)):
connection = mock.Mock()
with self.subTest(credentials=credentials), mock.patch.object(
clock_helper, "peer_credentials", return_value=credentials
), mock.patch.object(
clock_helper, "process_request"
) as process:
self.assertFalse(
clock_helper.handle_connection(
connection, allowed_uid=123, allowed_gid=456
)
)
process.assert_not_called()
connection.sendall.assert_called_once_with(
b"ERR unauthorized peer\n"
)
def test_authorized_peer_gets_one_bounded_request(self) -> None:
connection = mock.Mock()
with mock.patch.object(
clock_helper, "peer_credentials", return_value=(99, 123, 456)
), mock.patch.object(
clock_helper, "receive_request", return_value=b"sync\n"
) as receive, mock.patch.object(
clock_helper, "process_request", return_value="OK NTP sync requested"
) as process:
self.assertTrue(
clock_helper.handle_connection(
connection, allowed_uid=123, allowed_gid=456
)
)
receive.assert_called_once_with(connection)
process.assert_called_once_with(b"sync\n")
connection.sendall.assert_called_once_with(b"OK NTP sync requested\n")
def test_client_and_daemon_require_root_group_mode_0660_socket(self) -> None:
safe = mock.Mock(
st_mode=stat.S_IFSOCK | 0o660,
st_uid=0,
st_gid=456,
)
with mock.patch.object(Path, "lstat", return_value=safe):
service.validate_clock_control_socket(
Path("/run/test.sock"), expected_gid=456
)
clock_helper.validate_socket_metadata(safe, 456)
listener = mock.Mock(family=socket.AF_UNIX)
listener.getsockopt.side_effect = (socket.SOCK_STREAM, 1)
listener.getsockname.return_value = "/run/test.sock"
with mock.patch.object(Path, "lstat", return_value=safe):
clock_helper.validate_listening_socket(
listener,
path=Path("/run/test.sock"),
expected_gid=456,
)
wrong_type = mock.Mock(family=socket.AF_UNIX)
wrong_type.getsockopt.return_value = socket.SOCK_SEQPACKET
with self.assertRaisesRegex(ValueError, "SOCK_STREAM"):
clock_helper.validate_listening_socket(
wrong_type,
path=Path("/run/test.sock"),
expected_gid=456,
)
unsafe = (
mock.Mock(st_mode=stat.S_IFREG | 0o660, st_uid=0, st_gid=456),
mock.Mock(st_mode=stat.S_IFSOCK | 0o660, st_uid=1, st_gid=456),
mock.Mock(st_mode=stat.S_IFSOCK | 0o666, st_uid=0, st_gid=456),
mock.Mock(st_mode=stat.S_IFSOCK | 0o660, st_uid=0, st_gid=999),
)
for metadata in unsafe:
with self.subTest(metadata=metadata), mock.patch.object(
Path, "lstat", return_value=metadata
):
with self.assertRaises(ValueError):
service.validate_clock_control_socket(
Path("/run/test.sock"), expected_gid=456
)
with self.assertRaises(ValueError):
clock_helper.validate_socket_metadata(metadata, 456)
def test_endpoint_sends_exact_line_and_authenticates_root_server(self) -> None:
class Connection:
def __init__(self, server_uid: int = 0) -> None:
self.server_uid = server_uid
self.response = [b"OK NTP sync requested\n", b""]
self.sent = b""
self.connected = ""
self.shutdown_how: int | None = None
def __enter__(self) -> "Connection":
return self
def __exit__(self, *_args: object) -> None:
return None
def settimeout(self, _timeout: float) -> None:
pass
def connect(self, path: str) -> None:
self.connected = path
def getsockopt(self, *_args: object) -> bytes:
return struct.pack("3i", 99, self.server_uid, 0)
def sendall(self, value: bytes) -> None:
self.sent += value
def shutdown(self, how: int) -> None:
self.shutdown_how = how
def recv(self, _size: int) -> bytes:
return self.response.pop(0)
connection = Connection()
with mock.patch.object(
service, "validate_clock_control_socket"
) as validate, mock.patch.object(
service.socket, "socket", return_value=connection
) as constructor:
self.assertEqual(
"OK NTP sync requested",
service._exchange_clock_control("sync"),
)
constructor.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM)
validate.assert_called_once_with(
service.CLOCK_CONTROL_SOCKET,
expected_gid=os.getegid(),
)
self.assertEqual(str(service.CLOCK_CONTROL_SOCKET), connection.connected)
self.assertEqual(b"sync\n", connection.sent)
self.assertEqual(socket.SHUT_WR, connection.shutdown_how)
with mock.patch.object(
service, "validate_clock_control_socket"
), mock.patch.object(
service.socket, "socket", return_value=Connection(server_uid=1000)
):
with self.assertRaises(PermissionError):
service._exchange_clock_control("sync")
def test_endpoint_fails_closed_on_timeout_errors_and_bad_responses(self) -> None:
failures = (
(TimeoutError(), "Err - clock control timed out"),
(socket.timeout(), "Err - clock control timed out"),
(PermissionError(), "Err - clock control peer authentication failed"),
(FileNotFoundError(), "Err - clock control unavailable"),
(ValueError(), "Err - clock control unavailable"),
)
for failure, expected in failures:
with self.subTest(failure=failure), mock.patch.object(
service, "_exchange_clock_control", side_effect=failure
):
self.assertEqual(
expected,
service.run_clock_control("sync").text,
)
invalid = (
"OK clock set to 1788147001; NTP sync requested",
"OK NTP sync requested; reboot",
"PARTIAL clock set to 1788147001; NTP enable request failed",
"PARTIAL clock set to 1788147000; NTP enabled; "
"NTP enable request failed",
"PARTIAL clock set to 1788147000; chrony step request failed",
"PARTIAL clock set to 1788147000; "
"systemd-timesyncd restart failed",
"ERR attacker supplied text",
)
for response in invalid:
with self.subTest(response=response):
self.assertEqual(
"Err - invalid clock control response",
service._clock_control_result(
response, "set", 1788147000
).text,
)
def test_request_timeout_returns_error_without_mutation(self) -> None:
connection = mock.Mock()
with mock.patch.object(
clock_helper, "peer_credentials", return_value=(99, 123, 456)
), mock.patch.object(
clock_helper, "receive_request", side_effect=socket.timeout
), mock.patch.object(clock_helper, "process_request") as process:
self.assertFalse(
clock_helper.handle_connection(
connection, allowed_uid=123, allowed_gid=456
)
)
process.assert_not_called()
connection.sendall.assert_called_once_with(b"ERR request timed out\n")
trickle = mock.Mock()
trickle.recv.return_value = b"s"
with mock.patch.object(
clock_helper.time,
"monotonic",
side_effect=(0.0, 0.2, 1.1),
):
with self.assertRaises(socket.timeout):
clock_helper.receive_request(trickle)
trickle.recv.assert_called_once()
def test_child_commands_are_exact_bounded_argv_without_a_shell(self) -> None:
success = mock.Mock(returncode=0)
with mock.patch.object(
clock_helper.subprocess, "run", return_value=success
) as run, mock.patch.object(
clock_helper.Path, "is_file", return_value=True
), mock.patch.object(clock_helper.os, "access", return_value=True):
self.assertIsNone(clock_helper.request_ntp_sync())
self.assertEqual(2, run.call_count)
self.assertEqual(
["/usr/bin/timedatectl", "set-ntp", "true"],
run.call_args_list[0].args[0],
)
self.assertEqual(
[
"/usr/bin/systemctl",
"start",
clock_helper.CHRONY_STEP_SERVICE,
],
run.call_args_list[1].args[0],
)
self.assertNotIn(
"/usr/bin/chronyc",
(argument for call in run.call_args_list for argument in call.args[0]),
)
for call in run.call_args_list:
self.assertFalse(call.kwargs["shell"])
self.assertEqual(
clock_helper.CHILD_TIMEOUT_SECONDS, call.kwargs["timeout"]
)
self.assertEqual("/", call.kwargs["cwd"])
def test_systemd_examples_pin_socket_owner_group_and_mode(self) -> None:
directory = Path(__file__).resolve().parent
socket_unit = (directory / "meshcore-clock-control.socket").read_text(
encoding="utf-8"
)
service_unit = (directory / "meshcore-clock-control.service").read_text(
encoding="utf-8"
)
chrony_unit = (directory / "meshcore-chrony-step.service").read_text(
encoding="utf-8"
)
self.assertIn("ListenStream=/run/meshcore-clock-control.sock", socket_unit)
self.assertIn("SocketUser=root", socket_unit)
self.assertIn("SocketGroup=mctomqtt", socket_unit)
self.assertIn("SocketMode=0660", socket_unit)
self.assertIn("User=root", service_unit)
self.assertIn("--service-user mctomqtt", service_unit)
self.assertIn("--service-group mctomqtt", service_unit)
self.assertIn("CapabilityBoundingSet=CAP_SYS_TIME", service_unit)
self.assertNotIn("CAP_DAC_OVERRIDE", service_unit)
self.assertIn("Type=oneshot", chrony_unit)
self.assertIn("User=_chrony", chrony_unit)
self.assertIn("Group=_chrony", chrony_unit)
self.assertIn("ExecStart=/usr/bin/chronyc -a makestep", chrony_unit)
self.assertIn("TimeoutStartSec=1s", chrony_unit)
self.assertIn("NoNewPrivileges=yes", chrony_unit)
self.assertIn("\nCapabilityBoundingSet=\n", chrony_unit)
self.assertIn("\nAmbientCapabilities=\n", chrony_unit)
self.assertIn("RestrictAddressFamilies=AF_UNIX", chrony_unit)
self.assertIn("ProtectClock=yes", chrony_unit)
self.assertIn("ProtectSystem=strict", chrony_unit)
self.assertNotIn("CAP_", chrony_unit)
self.assertNotIn("[Install]", chrony_unit)
def test_activation_requires_one_named_systemd_descriptor_and_root(self) -> None:
listener = mock.Mock()
environment = {
"LISTEN_PID": "4242",
"LISTEN_FDS": "1",
"LISTEN_FDNAMES": "clock-control",
}
with mock.patch.object(
clock_helper.os, "getpid", return_value=4242
), mock.patch.object(
clock_helper.socket, "socket", return_value=listener
) as constructor:
self.assertIs(listener, clock_helper.activation_socket(environment))
constructor.assert_called_once_with(
fileno=clock_helper.SYSTEMD_LISTEN_FDS_START
)
invalid = (
{**environment, "LISTEN_PID": "7"},
{**environment, "LISTEN_FDS": "2"},
{**environment, "LISTEN_FDNAMES": "other"},
)
with mock.patch.object(clock_helper.os, "getpid", return_value=4242):
for variables in invalid:
with self.subTest(variables=variables), self.assertRaises(
ValueError
):
clock_helper.activation_socket(variables)
with mock.patch.object(clock_helper.os, "geteuid", return_value=1000), \
mock.patch.object(clock_helper, "activation_socket") as activate, \
mock.patch("builtins.print"):
self.assertEqual(
1,
clock_helper.main(
["--service-user", "mctomqtt", "--service-group", "mctomqtt"]
),
)
activate.assert_not_called()
class TokenTests(unittest.TestCase):
def test_signed_remote_serial_token_has_required_claims(self) -> None:
key = service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY)
def signer(message: bytes, public: bytes, private: bytes) -> bytes:
self.assertEqual(bytes.fromhex(PUBLIC_KEY), public)
self.assertEqual(bytes.fromhex(PRIVATE_KEY), private)
self.assertEqual(1, message.count(b"."))
return SIGNATURE
token = service.create_auth_token(
key,
{
"command": (
"host.reply 12345678 FEDCBA9876543210 CPU 47.2 C"
),
"target": "44" * 32,
"nonce": "nonce-value",
},
now=1000,
signer=signer,
)
header_part, payload_part, signature_part = token.split(".")
del header_part
padding = "=" * ((4 - len(payload_part) % 4) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_part + padding))
self.assertEqual(PUBLIC_KEY, claims["publicKey"])
self.assertEqual(1000, claims["iat"])
self.assertEqual(1030, claims["exp"])
self.assertEqual("44" * 32, claims["target"])
self.assertEqual(SIGNATURE.hex().upper(), signature_part)
class EndpointTests(unittest.TestCase):
def test_reboot_pending_broker_error_is_specific_and_validated(self) -> None:
operation_id = "A1" * 16
self.assertEqual(
"ERR reboot pending",
service._validate_host_action_response(
"ERR reboot pending", "prepare", "reboot", operation_id
),
)
self.assertEqual(
f"Err - another reboot is pending; {operation_id} not reserved",
service.HostCliEndpoint._prepare_failure_reply(
"reboot", operation_id, "ERR reboot pending"
),
)
def test_request_needs_matching_live_claim_before_action_is_queued(self) -> None:
events: list[object] = []
class PublishResult:
rc = 0
class Client:
def publish(self, topic: str, token: str, qos: int) -> PublishResult:
events.append(("publish", topic, token, qos))
return PublishResult()
endpoint = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
challenge_generator=lambda: "A1B2C3D4E5F60718",
monotonic=lambda: 100.0,
)
endpoint._enqueue_host_action = mock.Mock(return_value=True)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "reboot"
)
claim = service.HostClaim(
request.request_id,
request.request_nonce,
"A1B2C3D4E5F60718",
)
def make_token(
_key: service.ServiceKey, claims: dict[str, object]
) -> str:
events.append(("token", claims["command"]))
return "signed"
with mock.patch.object(
service, "parse_and_verify_request", return_value=request
), mock.patch.object(
service, "create_auth_token", side_effect=make_token
), mock.patch.object(service, "exchange_host_action") as broker:
self.assertTrue(endpoint.handle_mqtt_message(b"request"))
broker.assert_not_called()
with mock.patch.object(
service, "parse_and_verify_request", return_value=None
), mock.patch.object(
service, "parse_and_verify_claim", return_value=claim
):
self.assertTrue(endpoint.handle_mqtt_message(b"claim"))
self.assertEqual(
(
"token",
"host.reply 12345678 FEDCBA9876543210 "
"@claim=A1B2C3D4E5F60718",
),
events[0],
)
self.assertEqual("publish", events[1][0])
self.assertEqual(2, len(events))
expected_operation_id = service.host_action_operation_id(
"44" * 32, request
)
endpoint._enqueue_host_action.assert_called_once_with(
service.VerifiedHostAction(
request=request,
action="reboot",
operation_id=expected_operation_id,
)
)
broker.assert_not_called()
def test_real_action_worker_keeps_mqtt_callback_nonblocking(self) -> None:
started = threading.Event()
release = threading.Event()
finished = threading.Event()
class PublishResult:
rc = 0
def wait_for_publish(self, timeout: float) -> None:
self.timeout = timeout
def is_published(self) -> bool:
return True
class Client:
def publish(
self, _topic: str, _token: str, qos: int
) -> PublishResult:
self.qos = qos
return PublishResult()
def exchange(
verb: str,
operation_id: str,
action: str | None,
*,
deadline: float,
) -> str:
del deadline
if verb == "prepare":
started.set()
self.assertTrue(release.wait(timeout=2.0))
return f"PREPARED {action} {operation_id}"
finished.set()
return f"SCHEDULED {action} {operation_id}"
endpoint = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
host_action_exchange=exchange,
challenge_generator=lambda: "A1B2C3D4E5F60718",
monotonic=lambda: 100.0,
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "reboot"
)
claim = service.HostClaim(
request.request_id,
request.request_nonce,
"A1B2C3D4E5F60718",
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
):
endpoint._handle_request(request)
self.assertTrue(endpoint._handle_claim(claim))
self.assertTrue(started.wait(timeout=1.0))
self.assertFalse(release.is_set())
release.set()
self.assertTrue(finished.wait(timeout=2.0))
endpoint._action_queue.join()
def test_action_worker_orders_prepare_publish_confirmation_then_commit(
self,
) -> None:
events: list[object] = []
operation_id = "A1" * 16
class PublishResult:
rc = 0
def wait_for_publish(self, timeout: float) -> None:
events.append(("wait", timeout))
def is_published(self) -> bool:
events.append("is-published")
return True
class Client:
def publish(self, topic: str, token: str, qos: int) -> PublishResult:
events.append(("publish", topic, token, qos))
return PublishResult()
def exchange(
verb: str,
sent_operation_id: str,
action: str | None,
*,
deadline: float,
) -> str:
self.assertGreater(deadline, service.time.monotonic())
events.append((verb, sent_operation_id, action))
state_name = "PREPARED" if verb == "prepare" else "SCHEDULED"
return f"{state_name} {action} {sent_operation_id}"
endpoint = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
host_action_exchange=exchange,
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "reboot"
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
):
endpoint._process_host_action(
service.VerifiedHostAction(request, "reboot", operation_id)
)
self.assertEqual(
[
("prepare", operation_id, "reboot"),
("publish", "serial/commands", "signed", 1),
("wait", service.HOST_ACTION_PUBLISH_TIMEOUT_SECONDS),
"is-published",
("commit", operation_id, "reboot"),
],
events,
)
def test_action_worker_fails_closed_without_mqtt_confirmation(self) -> None:
operation_id = "A2" * 16
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "network restart"
)
class Client:
def __init__(self, publish_result: object) -> None:
self.publish_result = publish_result
def publish(self, _topic: str, _token: str, qos: int) -> object:
self.qos = qos
return self.publish_result
class Result:
rc = 0
def __init__(self, outcome: str) -> None:
self.outcome = outcome
def wait_for_publish(self, timeout: float) -> None:
self.timeout = timeout
if self.outcome == "raise":
raise RuntimeError("disconnected")
def is_published(self) -> bool:
return self.outcome == "published"
for label, publish_result in (
("missing-methods", mock.Mock(rc=0, spec=["rc"])),
("wait-error", Result("raise")),
("unpublished", Result("unpublished")),
):
calls: list[str] = []
def exchange(
verb: str,
sent_operation_id: str,
action: str | None,
*,
deadline: float,
) -> str:
del deadline
calls.append(verb)
return f"PREPARED {action} {sent_operation_id}"
endpoint = service.HostCliEndpoint(
client=Client(publish_result),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_network_restart=True,
host_action_exchange=exchange,
)
with self.subTest(label=label), mock.patch.object(
service, "create_auth_token", return_value="signed"
):
endpoint._process_host_action(
service.VerifiedHostAction(
request, "network-restart", operation_id
)
)
self.assertEqual(["prepare"], calls)
def test_action_worker_never_retries_an_ambiguous_commit(self) -> None:
operation_id = "A3" * 16
calls: list[str] = []
class PublishResult:
rc = 0
def wait_for_publish(self, timeout: float) -> None:
self.timeout = timeout
def is_published(self) -> bool:
return True
client = mock.Mock()
client.publish.return_value = PublishResult()
def exchange(
verb: str,
sent_operation_id: str,
action: str | None,
*,
deadline: float,
) -> str:
del deadline
calls.append(verb)
if verb == "commit":
raise socket.timeout("reply lost")
return f"PREPARED {action} {sent_operation_id}"
endpoint = service.HostCliEndpoint(
client=client,
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
host_action_exchange=exchange,
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
):
endpoint._process_host_action(
service.VerifiedHostAction(
service.HostRequest(
"12345678", "FEDCBA9876543210", "reboot"
),
"reboot",
operation_id,
)
)
self.assertEqual(["prepare", "commit"], calls)
def test_action_worker_does_not_commit_after_total_deadline(self) -> None:
operation_id = "A6" * 16
calls: list[str] = []
class PublishResult:
rc = 0
def wait_for_publish(self, timeout: float) -> None:
self.timeout = timeout
def is_published(self) -> bool:
return True
client = mock.Mock()
client.publish.return_value = PublishResult()
def exchange(
verb: str,
sent_operation_id: str,
action: str | None,
*,
deadline: float,
) -> str:
del deadline
calls.append(verb)
return f"PREPARED {action} {sent_operation_id}"
endpoint = service.HostCliEndpoint(
client=client,
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
host_action_exchange=exchange,
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
), mock.patch.object(
service.time, "monotonic", side_effect=(0.0, 1.0, 6.0)
), self.assertLogs(service.LOGGER, level="ERROR"):
endpoint._process_host_action(
service.VerifiedHostAction(
service.HostRequest(
"12345678", "FEDCBA9876543210", "reboot"
),
"reboot",
operation_id,
)
)
self.assertEqual(["prepare"], calls)
def test_action_status_reports_exact_broker_state_without_commit(self) -> None:
operation_id = "A4" * 16
calls: list[str] = []
client = mock.Mock()
client.publish.return_value = mock.Mock(rc=0)
def exchange(
verb: str,
sent_operation_id: str,
action: str | None,
*,
deadline: float,
) -> str:
del deadline
calls.append(verb)
self.assertIsNone(action)
return f"AMBIGUOUS reboot {sent_operation_id}"
endpoint = service.HostCliEndpoint(
client=client,
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
host_action_exchange=exchange,
)
request = service.HostRequest(
"12345678",
"FEDCBA9876543210",
f"action status {operation_id}",
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
) as token:
endpoint._process_host_action(
service.VerifiedHostAction(request, "status", operation_id)
)
self.assertEqual(["status"], calls)
self.assertIn(
f"Host action {operation_id}: reboot ambiguous",
token.call_args.args[1]["command"],
)
def test_overlapping_action_is_rejected_before_broker_reservation(self) -> None:
endpoint = service.HostCliEndpoint(
client=mock.Mock(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
)
self.assertTrue(endpoint._action_slot.acquire(blocking=False))
work = service.VerifiedHostAction(
service.HostRequest(
"12345678", "FEDCBA9876543210", "reboot"
),
"reboot",
"A5" * 16,
)
endpoint._publish_serial_reply = mock.Mock()
self.assertFalse(endpoint._enqueue_host_action(work))
endpoint._publish_serial_reply.assert_called_once_with(
work.request,
"Err - another host action is active; action was not reserved",
)
self.assertFalse(endpoint._action_worker_started)
endpoint._action_slot.release()
def test_mismatched_expired_and_replayed_claims_do_not_execute(self) -> None:
now = [10.0]
class Client:
def publish(self, _topic: str, _token: str, qos: int) -> mock.Mock:
self.qos = qos
return mock.Mock(rc=0)
endpoint = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
challenge_generator=lambda: "1111222233334444",
monotonic=lambda: now[0],
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "cpu-temp"
)
matching = service.HostClaim(
request.request_id, request.request_nonce, "1111222233334444"
)
wrong = service.HostClaim(
request.request_id, request.request_nonce, "9999888877776666"
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
), mock.patch.object(
service, "handle_request", return_value=service.HostResult("OK")
) as handler:
endpoint._handle_request(request)
endpoint._handle_claim(wrong)
handler.assert_not_called()
endpoint._handle_claim(matching)
handler.assert_called_once()
endpoint._handle_claim(matching)
handler.assert_called_once()
restarted = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
)
restarted._handle_claim(matching)
handler.assert_called_once()
later = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
challenge_generator=lambda: "5555666677778888",
challenge_timeout_seconds=8.0,
monotonic=lambda: now[0],
)
later._handle_request(request)
now[0] = 19.0
later._handle_claim(
service.HostClaim(
request.request_id,
request.request_nonce,
"5555666677778888",
)
)
handler.assert_called_once()
def test_run_program_is_not_started_by_request_record_alone(self) -> None:
client = mock.Mock()
client.publish.return_value = mock.Mock(rc=0)
programs = {
"sample": service.ProgramDefinition(
alias="sample",
argv=("/usr/bin/printf", "%s"),
arguments=(
service.ProgramArgumentRule(
"value", "choice", choices=("safe",)
),
),
)
}
endpoint = service.HostCliEndpoint(
client=client,
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
programs=programs,
challenge_generator=lambda: "ABCDEF0123456789",
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "run sample safe"
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
), mock.patch.object(service.subprocess, "run") as run:
endpoint._handle_request(request)
run.assert_not_called()
run.return_value = mock.Mock(returncode=0, stdout="done\n")
endpoint._handle_claim(
service.HostClaim(
request.request_id,
request.request_nonce,
"ABCDEF0123456789",
)
)
run.assert_called_once()
def test_failed_challenge_publish_clears_pending_state(self) -> None:
client = mock.Mock()
client.publish.return_value = mock.Mock(rc=7)
endpoint = service.HostCliEndpoint(
client=client,
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
challenge_generator=lambda: "ABCDEF0123456789",
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "cpu-temp"
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
), self.assertRaisesRegex(RuntimeError, "publish failed"):
endpoint._handle_request(request)
self.assertIsNone(endpoint.pending)
self.assertEqual({}, endpoint.seen)
if __name__ == "__main__":
unittest.main()