read_request called recv once for the length and once for the body. A stream socket can deliver a request in several pieces, and the server then rejected that request. The server now uses the read loop of the client, which moves to utilities as recv_exact. The server refuses a request above 64 KiB.
1128 lines
44 KiB
Python
1128 lines
44 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_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
|