socket_comms now raises DaemonError with the server message if the reply has the status ERROR. Before, the reply reached code that expects device data, and that code raised KeyError.
1143 lines
45 KiB
Python
1143 lines
45 KiB
Python
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
|