import json import signal import socket as socket_module import struct import subprocess import sys import threading import time from unittest.mock import patch import pytest import serial from bmspy.classes import BMSScalarField, BMSInfoField, UPS from bmspy.jbd_bms import JBDBMS from bmspy.server import ( DeviceState, collect_device_data, parse_device, read_request, send_response, ) def _poll_once(devices, stop, debug=0): """Collect once in the server thread. main() stops its polling threads only at process exit, so real ones would outlive a test and call later mocks.""" for device in devices.values(): device.data = collect_device_data(device, debug) device.polled.set() # --------------------------------------------------------------------------- # parse_device # --------------------------------------------------------------------------- class TestParseDevice: def test_plain_path(self): assert parse_device("/dev/ttyUSB0") == ("ttyUSB0", "/dev/ttyUSB0") def test_named_path(self): assert parse_device("myups:/dev/ttyUSB1") == ("myups", "/dev/ttyUSB1") def test_nested_path(self): assert parse_device("/dev/serial/by-id/usb-FTDI") == ( "usb-FTDI", "/dev/serial/by-id/usb-FTDI", ) def test_name_without_slash(self): # No "/" prefix, no ":" → treated as a plain path; last segment is name assert parse_device("ttyUSB0") == ("ttyUSB0", "ttyUSB0") def test_name_colon_path_no_leading_slash(self): assert parse_device("office:/dev/ttyUSB2") == ("office", "/dev/ttyUSB2") # --------------------------------------------------------------------------- # DeviceState # --------------------------------------------------------------------------- class TestDeviceState: def test_defaults(self): ser = serial.Serial() ds = DeviceState(ser=ser) assert ds.data is None assert ds.timestamp == 0.0 assert ds.ser is ser def test_fields_are_mutable(self): ser = serial.Serial() ds = DeviceState(ser=ser) ds.timestamp = 123.4 ds.data = UPS.from_dict({}) assert ds.timestamp == 123.4 assert isinstance(ds.data, UPS) # --------------------------------------------------------------------------- # read_request / send_response round-trip # --------------------------------------------------------------------------- def _send_framed(sock: socket_module.socket, data: dict) -> None: payload = json.dumps(data).encode() sock.sendall(struct.pack("!I", len(payload)) + payload) def _recv_framed(sock: socket_module.socket) -> dict: length = struct.unpack("!I", sock.recv(4))[0] return json.loads(sock.recv(length)) class TestReadRequest: def test_round_trip(self): srv, cli = socket_module.socketpair() try: _send_framed(cli, {"command": "GET", "client": "test"}) result = read_request(srv) assert result == {"command": "GET", "client": "test"} finally: srv.close() cli.close() def test_with_ups_filter(self): srv, cli = socket_module.socketpair() try: _send_framed(cli, {"command": "GET", "client": "test", "ups": "myups"}) result = read_request(srv) assert result["ups"] == "myups" finally: srv.close() cli.close() class TestSendResponse: def test_plain_dict(self): srv, cli = socket_module.socketpair() try: send_response(srv, {"status": "REGISTERED", "client": "test"}, "test") result = _recv_framed(cli) assert result == {"status": "REGISTERED", "client": "test"} finally: srv.close() cli.close() def test_ups_object_serialized_via_items(self): """UPS objects must be serialized using items(), not dataclass_asdict.""" bms = JBDBMS() bms.bms_voltage_total_volts = BMSScalarField( help="Total Voltage", raw_value=52.0, value="52.00", units="V" ) bms.bms_manufacture_date = BMSInfoField( help="Date of Manufacture", info="2023-01-15" ) srv, cli = socket_module.socketpair() try: send_response(srv, {"myups": bms}, "test") result = _recv_framed(cli) finally: srv.close() cli.close() assert "myups" in result assert "bms_voltage_total_volts" in result["myups"] assert result["myups"]["bms_voltage_total_volts"]["raw_value"] == 52.0 # None fields must not appear assert "bms_current_amps" not in result["myups"] # client field must not appear assert "client" not in result["myups"] def test_empty_ups_serializes_to_empty_dict(self): srv, cli = socket_module.socketpair() try: send_response(srv, {"myups": JBDBMS()}, "test") result = _recv_framed(cli) finally: srv.close() cli.close() assert result["myups"] == {} def test_plain_dict_passthrough(self): srv, cli = socket_module.socketpair() try: send_response(srv, {"key": "value", "number": 42}, "test") result = _recv_framed(cli) finally: srv.close() cli.close() assert result == {"key": "value", "number": 42} def test_closed_socket_raises_os_error(self): srv, cli = socket_module.socketpair() srv.close() cli.close() with pytest.raises(OSError): send_response(srv, {"status": "OK"}, "test") # --------------------------------------------------------------------------- # signalHandler # --------------------------------------------------------------------------- class TestSignalHandler: def test_raises_system_exit(self): from bmspy.server import signalHandler with pytest.raises(SystemExit): signalHandler(signal.SIGTERM, None) def test_exit_message_contains_terminating(self): from bmspy.server import signalHandler with pytest.raises(SystemExit, match="terminating"): signalHandler(signal.SIGTERM, None) def test_sigterm_during_accept_cleans_up_without_traceback(self, tmp_path): socket_path = tmp_path / "bms.sock" script = """ import atexit import os import signal import socket import sys import threading from bmspy.server import signalHandler, socket_cleanup signal.signal(signal.SIGTERM, signalHandler) listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) listener.bind(sys.argv[1]) listener.listen(1) atexit.register(socket_cleanup, sys.argv[1]) threading.Timer(0.1, os.kill, args=(os.getpid(), signal.SIGTERM)).start() listener.accept() """ result = subprocess.run( [sys.executable, "-c", script, str(socket_path)], capture_output=True, text=True, timeout=10, ) # Preserve the existing SystemExit("terminating") behavior. assert result.returncode == 1 assert result.stderr.strip() == "terminating" assert not socket_path.exists() # --------------------------------------------------------------------------- # socket_cleanup # --------------------------------------------------------------------------- class TestSocketCleanup: def test_removes_socket_file(self, tmp_path): from bmspy.server import socket_cleanup sock_file = tmp_path / "test.sock" sock_file.touch() assert sock_file.exists() socket_cleanup(str(sock_file)) assert not sock_file.exists() def test_raises_when_file_missing(self, tmp_path): from bmspy.server import socket_cleanup with pytest.raises(FileNotFoundError): socket_cleanup(str(tmp_path / "nonexistent.sock")) # --------------------------------------------------------------------------- # read_request — error paths # --------------------------------------------------------------------------- class TestReadRequestErrors: def test_invalid_json_raises_exception(self): srv, cli = socket_module.socketpair() try: invalid_payload = b"not valid json !!!" cli.sendall(struct.pack("!I", len(invalid_payload)) + invalid_payload) with pytest.raises(Exception, match="unable to read incoming request"): read_request(srv) finally: srv.close() cli.close() @pytest.mark.parametrize("split", [2, 10], ids=["inside-the-length", "inside-the-body"]) def test_request_in_two_pieces_is_read_whole(self, split): """A stream socket can deliver one request in several pieces.""" srv, cli = socket_module.socketpair(socket_module.AF_UNIX, socket_module.SOCK_STREAM) payload = json.dumps({"command": "GET", "client": "split"}).encode() frame = struct.pack("!I", len(payload)) + payload late = threading.Timer(0.05, cli.sendall, args=(frame[split:],)) try: srv.settimeout(5) cli.sendall(frame[:split]) late.start() assert read_request(srv) == {"command": "GET", "client": "split"} finally: late.join() srv.close() cli.close() def test_client_that_closes_after_half_a_request_is_an_error(self): srv, cli = socket_module.socketpair(socket_module.AF_UNIX, socket_module.SOCK_STREAM) payload = json.dumps({"command": "GET", "client": "half"}).encode() try: cli.sendall(struct.pack("!I", len(payload)) + payload[:10]) cli.close() with pytest.raises(OSError, match="unable to read socket: socket closed after 10 of"): read_request(srv) finally: srv.close() def test_request_above_the_size_limit_is_refused(self): """A wrong length prefix must not make the server wait for gigabytes.""" from bmspy.server import MAX_REQUEST_BYTES srv, cli = socket_module.socketpair(socket_module.AF_UNIX, socket_module.SOCK_STREAM) try: cli.sendall(struct.pack("!I", MAX_REQUEST_BYTES + 1) + b"{}") with pytest.raises(OSError, match="exceeds the limit"): read_request(srv) finally: srv.close() cli.close() def test_truncated_length_bytes_raises(self): srv, cli = socket_module.socketpair() try: cli.sendall(b"\x00\x00") # only 2 of 4 length bytes, then close cli.close() with pytest.raises(Exception): read_request(srv) finally: srv.close() def test_recv_raises_os_error(self): """When recv raises on first read, read_request should raise OSError.""" from unittest.mock import MagicMock mock_conn = MagicMock() mock_conn.recv.side_effect = OSError("connection reset") with pytest.raises(OSError, match="unable to read request length"): read_request(mock_conn) def test_recv_body_raises_os_error(self): """When recv raises on second read (body), should raise OSError.""" from unittest.mock import MagicMock import struct mock_conn = MagicMock() length_bytes = struct.pack("!I", 10) # First recv returns valid length bytes, second recv raises mock_conn.recv.side_effect = [length_bytes, OSError("body read error")] with pytest.raises(OSError, match="unable to read socket"): read_request(mock_conn) def test_debug_5_logs_length(self, capsys): srv, cli = socket_module.socketpair() try: _send_framed(cli, {"command": "GET", "client": "test"}) read_request(srv, debug=5) finally: srv.close() cli.close() # debug > 4 logs incoming length captured = capsys.readouterr() assert "incoming length" in captured.out def test_debug_4_logs_request_bytes(self, capsys): srv, cli = socket_module.socketpair() try: _send_framed(cli, {"command": "GET", "client": "test"}) read_request(srv, debug=4) finally: srv.close() cli.close() captured = capsys.readouterr() assert "incoming request" in captured.out def test_debug_3_logs_received(self, capsys): srv, cli = socket_module.socketpair() try: _send_framed(cli, {"command": "GET", "client": "test"}) read_request(srv, debug=3) finally: srv.close() cli.close() captured = capsys.readouterr() assert "received" in captured.out class TestServerMain: """Test the server main() function by running it in a thread and sending real socket commands.""" @pytest.fixture(autouse=True) def _stoppable_accept(self): """Let a test end main(). The server itself has no command that stops it.""" self._stopping = stopping = threading.Event() original_accept = socket_module.socket.accept def accept_or_stop(sock): connection, address = original_accept(sock) if stopping.is_set(): connection.close() raise KeyboardInterrupt return connection, address with patch.object(socket_module.socket, "accept", accept_or_stop): yield def _make_server_thread(self, sock_path: str, ready_event: threading.Event, stop_event: threading.Event, **kwargs): """Run server main() in a thread with mocked serial and collect_data.""" import socket as _socket from unittest.mock import MagicMock, patch from bmspy.server import main as server_main from bmspy.classes import BMSScalarField from bmspy.jbd_bms import JBDBMS # Build a fake JBDBMS result fake_bms = JBDBMS() fake_bms.bms_voltage_total_volts = BMSScalarField( help="Voltage", raw_value=52.0, value="52.00", units="V" ) def _do_main(): import sys import time as _t argv = ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"] if "debug" in kwargs: argv += ["-v"] * kwargs["debug"] original_listen = socket_module.socket.listen def _patched_listen(self, backlog=1): result = original_listen(self, backlog) ready_event.set() return result with patch("sys.argv", argv), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.start_polling", _poll_once), \ patch("bmspy.server.time.sleep"), \ patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.exists", return_value=False), \ patch.object(socket_module.socket, "listen", _patched_listen): try: server_main() except (SystemExit, KeyboardInterrupt, OSError): pass t = threading.Thread(target=_do_main, daemon=True) return t def _send_command(self, sock_path: str, cmd: dict) -> dict: """Connect to server socket and send a command.""" import socket as _socket import struct import json sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) # Wait for server to be ready for _ in range(20): try: sock.connect(sock_path) break except (OSError, ConnectionRefusedError): import time time.sleep(0.1) payload = json.dumps(cmd).encode() sock.sendall(struct.pack("!I", len(payload)) + payload) # Read response raw_len = sock.recv(4) if not raw_len: return {} length = struct.unpack("!I", raw_len)[0] resp_data = sock.recv(length) sock.close() return json.loads(resp_data) def _stop_server(self, sock_path: str, thread: threading.Thread) -> None: """End main(), so that its threads and its patches do not outlive the test. One more connection wakes accept(), which then stops the server. """ self._stopping.set() if thread.is_alive(): sock = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM) try: sock.connect(sock_path) except OSError: pass # The server ended between the check and the connection. finally: sock.close() thread.join(timeout=5) assert not thread.is_alive() for client_thread in threading.enumerate(): if client_thread.name == "client": client_thread.join(timeout=5) assert not client_thread.is_alive() def test_register_command(self, tmp_path): """Test REGISTER command via server main().""" sock_path = str(tmp_path / "server.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop) t.start() ready.wait(timeout=2) import time as _time _time.sleep(0.2) # Let server get to sock.accept() try: response = self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) assert response.get("status") == "REGISTERED" finally: self._stop_server(sock_path, t) def test_get_command(self, tmp_path): """Test GET command via server main().""" sock_path = str(tmp_path / "server_get.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop) t.start() ready.wait(timeout=2) import time as _time _time.sleep(0.2) try: response = self._send_command(sock_path, {"command": "GET", "client": "test"}) assert "ttyUSB0" in response or len(response) >= 0 finally: self._stop_server(sock_path, t) def test_unknown_command_is_a_clear_error_in_the_client(self, tmp_path): """The real client, against a server that does not know the command.""" from bmspy import client sock_path = str(tmp_path / "server_refused.sock") ready = threading.Event() t = self._make_server_thread(sock_path, ready, threading.Event()) t.start() ready.wait(timeout=2) try: with pytest.raises(client.DaemonError, match="daemon refused 'NEWER': unknown command: 'NEWER'"): client.socket_comms(sock_path, {"command": "NEWER", "client": "test"}) finally: self._stop_server(sock_path, t) def test_devices_command_lists_configured_devices(self, tmp_path): """DEVICES names every configured device, through the real client.""" from bmspy import client sock_path = str(tmp_path / "server_devices.sock") ready = threading.Event() t = self._make_server_thread(sock_path, ready, threading.Event()) t.start() ready.wait(timeout=2) try: assert client.list_devices(sock_path, "test") == ["ttyUSB0"] finally: self._stop_server(sock_path, t) def test_deregister_command(self, tmp_path): """Test DEREGISTER command via server main().""" sock_path = str(tmp_path / "server_dereg.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: # First register r1 = self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) assert r1.get("status") == "REGISTERED" # Then deregister r2 = self._send_command(sock_path, {"command": "DEREGISTER", "client": "test"}) assert r2.get("status") == "DEREGISTERED" finally: self._stop_server(sock_path, t) def test_get_with_ups_filter(self, tmp_path): """Test GET command with ups filter.""" sock_path = str(tmp_path / "server_getups.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: response = self._send_command(sock_path, {"command": "GET", "client": "test", "ups": "ttyUSB0"}) # Should get ttyUSB0 data or empty (ups filter) assert isinstance(response, dict) finally: self._stop_server(sock_path, t) def test_debug_mode_verbose(self, tmp_path, capsys): """Test server main() with debug=1 logs messages.""" sock_path = str(tmp_path / "server_dbg.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop, debug=1) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: self._send_command(sock_path, {"command": "GET", "client": "test"}) except Exception: pass finally: self._stop_server(sock_path, t) def test_duplicate_device_name_skipped(self, tmp_path, capsys): """Test that duplicate UPS names are skipped.""" sock_path = str(tmp_path / "server_dup.sock") ready = threading.Event() from unittest.mock import MagicMock, patch from bmspy.server import main as server_main from bmspy.classes import BMSScalarField from bmspy.jbd_bms import JBDBMS fake_bms = JBDBMS() fake_bms.bms_voltage_total_volts = BMSScalarField( help="Voltage", raw_value=52.0, value="52.00", units="V" ) def _do_main_dup(): argv = ["bmspy-server", "--socket", sock_path, "--device", "myups:/dev/ttyUSB0", "--device", "myups:/dev/ttyUSB1"] # duplicate name original_listen = socket_module.socket.listen def _patched_listen(self, backlog=1): result = original_listen(self, backlog) ready.set() return result with patch("sys.argv", argv), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.start_polling", _poll_once), \ patch("bmspy.server.time.sleep"), \ patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.exists", return_value=False), \ patch.object(socket_module.socket, "listen", _patched_listen): try: server_main() except (SystemExit, KeyboardInterrupt, OSError): pass t = threading.Thread(target=_do_main_dup, daemon=True) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: # Server should have started with one device response = self._send_command(sock_path, {"command": "GET", "client": "test"}) assert "myups" in response finally: self._stop_server(sock_path, t) def test_socket_dir_created_if_missing(self, tmp_path): """Test that socket dir is created when it doesn't exist.""" sock_path = str(tmp_path / "server_mkdir.sock") ready = threading.Event() from unittest.mock import MagicMock, patch, call from bmspy.server import main as server_main from bmspy.jbd_bms import JBDBMS fake_bms = JBDBMS() makedirs_called = [] def _do_main_mkdir(): argv = ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"] original_listen = socket_module.socket.listen def _patched_listen(self, backlog=1): result = original_listen(self, backlog) ready.set() return result def _patched_makedirs(path, exist_ok=False): makedirs_called.append(path) # Don't call actual makedirs to avoid recursion - socket dir is already tmp_path with patch("sys.argv", argv), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.start_polling", _poll_once), \ patch("bmspy.server.time.sleep"), \ patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.path.isdir", return_value=False), \ patch("bmspy.server.os.makedirs", side_effect=_patched_makedirs), \ patch.object(socket_module.socket, "listen", _patched_listen): try: server_main() except (SystemExit, KeyboardInterrupt, OSError): pass t = threading.Thread(target=_do_main_mkdir, daemon=True) t.start() ready.wait(timeout=5) import time as _t _t.sleep(0.2) try: self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) except Exception: pass self._stop_server(sock_path, t) assert len(makedirs_called) > 0 def test_debug_3_logs_startup(self, tmp_path, capsys): """Test debug=3 logs 'starting up' message.""" sock_path = str(tmp_path / "server_dbg3.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop, debug=3) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) except Exception: pass finally: self._stop_server(sock_path, t) captured = capsys.readouterr() # debug>2 triggers "starting up" and "waiting for connection" assert "starting up" in captured.out.lower() or "waiting" in captured.out.lower() def test_socket_already_exists_raises(self, tmp_path): """Test that server raises OSError if socket already exists.""" sock_path = str(tmp_path / "existing.sock") from unittest.mock import MagicMock, patch from bmspy.server import main as server_main with patch("sys.argv", ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"]), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.exists", return_value=True): with pytest.raises(OSError, match="already exists"): server_main() def test_deregister_nonexistent_client_no_error(self, tmp_path): """Test DEREGISTER for a client that was never registered (KeyError suppressed).""" sock_path = str(tmp_path / "server_noerr.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: # Deregister without first registering response = self._send_command(sock_path, {"command": "DEREGISTER", "client": "ghost"}) assert response.get("status") == "DEREGISTERED" finally: self._stop_server(sock_path, t) def test_keyboard_interrupt_ends_the_server(self, tmp_path): """An interrupt reaches main() while it waits for a client.""" sock_path = str(tmp_path / "server_kbi.sock") with patch("sys.argv", ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"]), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.atexit.register"), \ patch("bmspy.server.initialise_serial"), \ patch("bmspy.server.start_polling"), \ patch("bmspy.server.os.path.isdir", return_value=True), \ patch.object(socket_module.socket, "accept", side_effect=KeyboardInterrupt): from bmspy.server import main as server_main with pytest.raises(SystemExit) as exit_info: server_main() assert exit_info.value.code == 1 def test_socket_read_error_logs_and_continues(self, tmp_path, capsys): """Test that read_request errors are caught and logged.""" sock_path = str(tmp_path / "server_err.sock") ready = threading.Event() stop = threading.Event() t = self._make_server_thread(sock_path, ready, stop) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: # Connect but send garbage that will cause read_request to fail import socket as _s sock = _s.socket(_s.AF_UNIX, _s.SOCK_STREAM) for _ in range(20): try: sock.connect(sock_path) break except (OSError, ConnectionRefusedError): _t.sleep(0.05) # Send only 2 bytes (incomplete length header) sock.sendall(b"\x00\x00") sock.close() _t.sleep(0.1) # Give server time to process finally: self._stop_server(sock_path, t) def test_root_user_socket_dir_created(self, tmp_path, capsys): """Test root user path when socket dir doesn't exist (chown/chmod triggered).""" sock_path = str(tmp_path / "server_root_mkdir.sock") ready = threading.Event() from unittest.mock import MagicMock, patch from bmspy.server import main as server_main from bmspy.jbd_bms import JBDBMS fake_bms = JBDBMS() def _do_main(): argv = ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0", "-v", "-v"] original_listen = socket_module.socket.listen def _patched_listen(self, backlog=1): result = original_listen(self, backlog) ready.set() return result mock_pwd_module = MagicMock() mock_pwd_module.getpwnam.return_value = [None, None, 65534] mock_pwd_module.getpwuid.return_value = ["nobody"] mock_grp_module = MagicMock() mock_grp_module.getgrnam.return_value = [None, None, 65534] mock_grp_module.getgrgid.return_value = ["dialout"] import sys as _sys with patch("sys.argv", argv), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.start_polling", _poll_once), \ patch("bmspy.server.time.sleep"), \ patch("bmspy.server.os.path.isdir", return_value=False), \ patch("bmspy.server.os.makedirs"), \ patch("bmspy.server.os.chown"), \ patch("bmspy.server.os.chmod"), \ patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.getuid", return_value=0), \ patch("bmspy.server.os.getgid", return_value=0), \ patch("bmspy.server.os.setuid"), \ patch("bmspy.server.os.setgid"), \ patch("bmspy.server.os.umask", return_value=0o022), \ patch.dict(_sys.modules, {"pwd": mock_pwd_module, "grp": mock_grp_module}), \ patch.object(socket_module.socket, "listen", _patched_listen): try: server_main() except (SystemExit, KeyboardInterrupt, OSError): pass t = threading.Thread(target=_do_main, daemon=True) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) except Exception: pass self._stop_server(sock_path, t) def test_root_user_setgid_error(self, tmp_path, capsys): """Test root user path when setgid raises OSError.""" sock_path = str(tmp_path / "server_setgid_err.sock") ready = threading.Event() from unittest.mock import MagicMock, patch from bmspy.server import main as server_main from bmspy.jbd_bms import JBDBMS def _do_main(): argv = ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"] original_listen = socket_module.socket.listen def _patched_listen(self, backlog=1): result = original_listen(self, backlog) ready.set() return result mock_pwd_module = MagicMock() mock_pwd_module.getpwnam.return_value = [None, None, 65534] mock_pwd_module.getpwuid.return_value = ["nobody"] mock_grp_module = MagicMock() mock_grp_module.getgrnam.return_value = [None, None, 65534] mock_grp_module.getgrgid.return_value = ["dialout"] import sys as _sys with patch("sys.argv", argv), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.collect_data", return_value=JBDBMS()), \ patch("bmspy.server.start_polling", _poll_once), \ patch("bmspy.server.time.sleep"), \ patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.getuid", return_value=0), \ patch("bmspy.server.os.getgid", return_value=0), \ patch("bmspy.server.os.setgid", side_effect=OSError("cannot set gid")), \ patch("bmspy.server.os.setuid", side_effect=OSError("cannot set uid")), \ patch("bmspy.server.os.umask", return_value=0o022), \ patch.dict(_sys.modules, {"pwd": mock_pwd_module, "grp": mock_grp_module}), \ patch.object(socket_module.socket, "listen", _patched_listen): try: server_main() except (SystemExit, KeyboardInterrupt, OSError): pass t = threading.Thread(target=_do_main, daemon=True) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) except Exception: pass self._stop_server(sock_path, t) captured = capsys.readouterr() # Should log errors about setgid/setuid assert "gid" in captured.out.lower() or "uid" in captured.out.lower() def test_root_user_uid_gid_handling(self, tmp_path, capsys): """Test the uid==0 path for privilege dropping.""" sock_path = str(tmp_path / "server_root.sock") ready = threading.Event() from unittest.mock import MagicMock, patch from bmspy.server import main as server_main from bmspy.jbd_bms import JBDBMS fake_bms = JBDBMS() def _do_main_root(): argv = ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"] original_listen = socket_module.socket.listen def _patched_listen(self, backlog=1): result = original_listen(self, backlog) ready.set() return result mock_pwd_module = MagicMock() mock_pwd_module.getpwnam.return_value = [None, None, 65534] # nobody uid mock_pwd_module.getpwuid.return_value = ["nobody"] mock_grp_module = MagicMock() mock_grp_module.getgrnam.return_value = [None, None, 65534] # dialout gid mock_grp_module.getgrgid.return_value = ["dialout"] import sys as _sys with patch("sys.argv", argv), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.start_polling", _poll_once), \ patch("bmspy.server.time.sleep"), \ patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.getuid", return_value=0), \ patch("bmspy.server.os.getgid", return_value=0), \ patch("bmspy.server.os.setuid"), \ patch("bmspy.server.os.setgid"), \ patch("bmspy.server.os.umask", return_value=0o022), \ patch.dict(_sys.modules, {"pwd": mock_pwd_module, "grp": mock_grp_module}), \ patch.object(socket_module.socket, "listen", _patched_listen): try: server_main() except (SystemExit, KeyboardInterrupt, OSError): pass t = threading.Thread(target=_do_main_root, daemon=True) t.start() ready.wait(timeout=2) import time as _t _t.sleep(0.2) try: self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) except Exception: pass self._stop_server(sock_path, t) @pytest.mark.parametrize("request_data,command", [ ({"command": "INVALID", "client": "test"}, "'INVALID'"), ({"client": "test"}, "None"), (["GET"], "None"), ], ids=["unknown-command", "no-command", "not-an-object"]) def test_invalid_request_gets_an_error_and_the_server_continues( self, tmp_path, capsys, request_data, command, ): """One bad request must not end the server for every other client.""" sock_path = str(tmp_path / "server_invalid.sock") ready = threading.Event() t = self._make_server_thread(sock_path, ready, threading.Event()) t.start() ready.wait(timeout=2) try: assert self._send_command(sock_path, request_data) == { "status": "ERROR", "message": "unknown command: " + command, } assert t.is_alive() # The next client still gets its answer. response = self._send_command(sock_path, {"command": "GET", "client": "test"}) assert "ttyUSB0" in response finally: self._stop_server(sock_path, t) assert "invalid request from" in capsys.readouterr().out def test_client_that_disconnects_before_the_reply_does_not_end_the_server(self, tmp_path, capsys): """The reply to a closed connection fails. The next client must still get an answer.""" sock_path = str(tmp_path / "server_gone.sock") ready, client_closed = threading.Event(), threading.Event() def send_after_close(*args, **kwargs): # Hold the reply until the client is gone, so that the send fails each time. client_closed.wait(timeout=5) return send_response(*args, **kwargs) t = self._make_server_thread(sock_path, ready, threading.Event()) with patch("bmspy.server.send_response", send_after_close): t.start() ready.wait(timeout=2) try: sock = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM) sock.connect(sock_path) payload = json.dumps({"command": "GET", "client": "gone"}).encode() sock.sendall(struct.pack("!I", len(payload)) + payload) sock.close() client_closed.set() response = self._send_command(sock_path, {"command": "REGISTER", "client": "test"}) assert response == {"status": "REGISTERED", "client": "test"} assert t.is_alive() finally: client_closed.set() self._stop_server(sock_path, t) assert "socket ERROR: unable to encode response" in capsys.readouterr().out def test_clients_are_served_at_the_same_time(self, tmp_path, monkeypatch): """A client that is slow to send its request must not delay another client.""" monkeypatch.setattr("bmspy.server.CLIENT_TIMEOUT_SECONDS", 30) sock_path = str(tmp_path / "server_two.sock") ready = threading.Event() t = self._make_server_thread(sock_path, ready, threading.Event()) t.start() ready.wait(timeout=2) slow = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM) slow.settimeout(5) try: # The first client connects and sends nothing yet. slow.connect(sock_path) start = time.monotonic() response = self._send_command(sock_path, {"command": "REGISTER", "client": "fast"}) assert response == {"status": "REGISTERED", "client": "fast"} assert time.monotonic() - start < 5 # The first connection is still open, and gets its own answer. payload = json.dumps({"command": "DEVICES", "client": "slow"}).encode() slow.sendall(struct.pack("!I", len(payload)) + payload) length = struct.unpack("!I", slow.recv(4))[0] assert json.loads(slow.recv(length)) == {"devices": ["ttyUSB0"]} finally: slow.close() self._stop_server(sock_path, t) def test_silent_client_is_disconnected_at_the_time_limit(self, tmp_path, capsys, monkeypatch): """A client that never sends a request must not keep its thread forever.""" monkeypatch.setattr("bmspy.server.CLIENT_TIMEOUT_SECONDS", 0.2) sock_path = str(tmp_path / "server_silent.sock") ready = threading.Event() t = self._make_server_thread(sock_path, ready, threading.Event()) t.start() ready.wait(timeout=2) silent = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM) silent.settimeout(5) try: silent.connect(sock_path) # An empty read means that the server closed the connection. assert silent.recv(1) == b"" finally: silent.close() self._stop_server(sock_path, t) assert "socket ERROR: unable to read request length from socket: timed out" in capsys.readouterr().out def test_failed_accept_still_ends_the_server(self, tmp_path): """A listening socket that fails must not become an endless loop.""" sock_path = str(tmp_path / "server_accept.sock") with patch("sys.argv", ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"]), \ patch("bmspy.server.signal.signal"), \ patch("bmspy.server.atexit.register"), \ patch("bmspy.server.initialise_serial"), \ patch("bmspy.server.start_polling"), \ patch("bmspy.server.os.path.isdir", return_value=True), \ patch.object(socket_module.socket, "accept", side_effect=OSError("accept failed")) as accept: from bmspy.server import main as server_main with pytest.raises(OSError, match="accept failed"): server_main() assert accept.call_count == 1 class TestSendResponseDebug: def test_debug_3_logs_sending(self, capsys): srv, cli = socket_module.socketpair() try: send_response(srv, {"status": "OK"}, "test", debug=3) _recv_framed(cli) finally: srv.close() cli.close() captured = capsys.readouterr() assert "sending" in captured.out def test_debug_5_logs_length(self, capsys): srv, cli = socket_module.socketpair() try: send_response(srv, {"status": "OK"}, "test", debug=5) _recv_framed(cli) finally: srv.close() cli.close() captured = capsys.readouterr() assert "length" in captured.out def test_debug_4_logs_response(self, capsys): srv, cli = socket_module.socketpair() try: send_response(srv, {"status": "OK"}, "test", debug=4) _recv_framed(cli) finally: srv.close() cli.close() captured = capsys.readouterr() assert "outgoing response" in captured.out