- tests/**: update templates/ → decnet/templates/ paths after module move - tests/mysql_spinup.sh: use root:root and asyncmy driver - tests/test_auto_spawn.py: patch decnet.cli.utils._pid_dir (package split) - tests/test_cli.py: set DECNET_MODE=master in api-command tests - tests/stress/conftest.py: run locust out-of-process via its CLI + CSV stats shim to avoid urllib3 RecursionError from late gevent monkey-patch; raise uvicorn startup timeout to 60s, accept 401 from auth-gated health, strip inherited DECNET_* env, surface stderr on 0-request runs - tests/stress/test_stress.py: loosen baseline thresholds to match hw
186 lines
6.4 KiB
Python
186 lines
6.4 KiB
Python
"""
|
|
Tests for decnet/templates/mqtt/server.py — protocol boundary and fuzz cases.
|
|
|
|
Focuses on the variable-length remaining-length field (MQTT spec: max 4 bytes).
|
|
A 5th continuation byte used to cause the server to get stuck waiting for a
|
|
payload it could never receive (remaining = hundreds of MB).
|
|
"""
|
|
|
|
import importlib.util
|
|
import struct
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from .conftest import _FUZZ_SETTINGS, make_fake_syslog_bridge, run_with_timeout
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _load_mqtt():
|
|
for key in list(sys.modules):
|
|
if key in ("mqtt_server", "syslog_bridge"):
|
|
del sys.modules[key]
|
|
sys.modules["syslog_bridge"] = make_fake_syslog_bridge()
|
|
spec = importlib.util.spec_from_file_location("mqtt_server", "decnet/templates/mqtt/server.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
with patch.dict("os.environ", {"MQTT_ACCEPT_ALL": "1", "MQTT_PERSONA": "water_plant"}, clear=False):
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def _make_protocol(mod):
|
|
proto = mod.MQTTProtocol()
|
|
transport = MagicMock()
|
|
written: list[bytes] = []
|
|
transport.write.side_effect = written.append
|
|
proto.connection_made(transport)
|
|
return proto, transport, written
|
|
|
|
|
|
def _connect_packet(client_id: str = "test-client") -> bytes:
|
|
"""Build a minimal MQTT CONNECT packet."""
|
|
proto_name = b"\x00\x04MQTT"
|
|
proto_level = b"\x04" # 3.1.1
|
|
flags = b"\x02" # clean session
|
|
keepalive = b"\x00\x3c"
|
|
cid = client_id.encode()
|
|
cid_field = struct.pack(">H", len(cid)) + cid
|
|
payload = proto_name + proto_level + flags + keepalive + cid_field
|
|
remaining = len(payload)
|
|
# single-byte remaining length (works for short payloads)
|
|
return bytes([0x10, remaining]) + payload
|
|
|
|
|
|
def _encode_remaining(value: int) -> bytes:
|
|
"""Encode a value using MQTT variable-length encoding."""
|
|
result = []
|
|
while True:
|
|
encoded = value % 128
|
|
value //= 128
|
|
if value > 0:
|
|
encoded |= 128
|
|
result.append(encoded)
|
|
if value == 0:
|
|
break
|
|
return bytes(result)
|
|
|
|
|
|
@pytest.fixture
|
|
def mqtt_mod():
|
|
return _load_mqtt()
|
|
|
|
|
|
# ── Happy path ────────────────────────────────────────────────────────────────
|
|
|
|
def test_connect_returns_connack_accepted(mqtt_mod):
|
|
proto, _, written = _make_protocol(mqtt_mod)
|
|
proto.data_received(_connect_packet())
|
|
resp = b"".join(written)
|
|
assert resp[:2] == b"\x20\x02" # CONNACK
|
|
assert resp[3:4] == b"\x00" # return code 0 = accepted
|
|
|
|
|
|
def test_connect_sets_auth_flag(mqtt_mod):
|
|
proto, _, _ = _make_protocol(mqtt_mod)
|
|
proto.data_received(_connect_packet())
|
|
assert proto._auth is True
|
|
|
|
|
|
def test_pingreq_returns_pingresp(mqtt_mod):
|
|
proto, _, written = _make_protocol(mqtt_mod)
|
|
proto.data_received(_connect_packet())
|
|
written.clear()
|
|
proto.data_received(b"\xc0\x00") # PINGREQ
|
|
assert b"\xd0\x00" in b"".join(written)
|
|
|
|
|
|
def test_disconnect_closes_transport(mqtt_mod):
|
|
proto, transport, _ = _make_protocol(mqtt_mod)
|
|
proto.data_received(_connect_packet())
|
|
transport.reset_mock()
|
|
proto.data_received(b"\xe0\x00") # DISCONNECT
|
|
transport.close.assert_called()
|
|
|
|
|
|
def test_publish_without_auth_closes(mqtt_mod):
|
|
proto, transport, _ = _make_protocol(mqtt_mod)
|
|
# PUBLISH without prior CONNECT
|
|
topic = b"\x00\x04test"
|
|
payload = b"hello"
|
|
remaining = len(topic) + len(payload)
|
|
proto.data_received(bytes([0x30, remaining]) + topic + payload)
|
|
transport.close.assert_called()
|
|
|
|
|
|
def test_partial_packet_waits_for_more(mqtt_mod):
|
|
proto, transport, _ = _make_protocol(mqtt_mod)
|
|
proto.data_received(b"\x10") # just the first byte
|
|
transport.close.assert_not_called()
|
|
|
|
|
|
def test_connection_lost_does_not_raise(mqtt_mod):
|
|
proto, _, _ = _make_protocol(mqtt_mod)
|
|
proto.connection_lost(None)
|
|
|
|
|
|
# ── Regression: overlong remaining-length field ───────────────────────────────
|
|
|
|
def test_5_continuation_bytes_closes(mqtt_mod):
|
|
proto, transport, _ = _make_protocol(mqtt_mod)
|
|
# 5 bytes with continuation bit set, then a final byte
|
|
# MQTT spec allows max 4 bytes — this must be rejected
|
|
data = bytes([0x30, 0x80, 0x80, 0x80, 0x80, 0x01])
|
|
run_with_timeout(proto.data_received, data)
|
|
transport.close.assert_called()
|
|
|
|
|
|
def test_6_continuation_bytes_closes(mqtt_mod):
|
|
proto, transport, _ = _make_protocol(mqtt_mod)
|
|
data = bytes([0x30]) + bytes([0x80] * 6) + b"\x01"
|
|
run_with_timeout(proto.data_received, data)
|
|
transport.close.assert_called()
|
|
|
|
|
|
def test_4_continuation_bytes_is_accepted(mqtt_mod):
|
|
proto, transport, _ = _make_protocol(mqtt_mod)
|
|
# 4 bytes total for remaining length = max allowed.
|
|
# remaining = 0x0FFFFFFF = 268435455 bytes — huge but spec-valid encoding.
|
|
# With no data following, it simply returns (incomplete payload) — not closed.
|
|
data = bytes([0x30, 0xff, 0xff, 0xff, 0x7f])
|
|
run_with_timeout(proto.data_received, data)
|
|
transport.close.assert_not_called()
|
|
|
|
|
|
def test_zero_remaining_publish_does_not_close(mqtt_mod):
|
|
proto, transport, _ = _make_protocol(mqtt_mod)
|
|
proto.data_received(_connect_packet())
|
|
transport.reset_mock()
|
|
# PUBLISH with remaining=0 is unusual but not a protocol violation
|
|
proto.data_received(b"\x30\x00")
|
|
transport.close.assert_not_called()
|
|
|
|
|
|
# ── Fuzz ──────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.fuzz
|
|
@given(data=st.binary(min_size=0, max_size=512))
|
|
@settings(**_FUZZ_SETTINGS)
|
|
def test_fuzz_unauthenticated(data):
|
|
mod = _load_mqtt()
|
|
proto, _, _ = _make_protocol(mod)
|
|
run_with_timeout(proto.data_received, data)
|
|
|
|
|
|
@pytest.mark.fuzz
|
|
@given(data=st.binary(min_size=0, max_size=512))
|
|
@settings(**_FUZZ_SETTINGS)
|
|
def test_fuzz_after_connect(data):
|
|
mod = _load_mqtt()
|
|
proto, _, _ = _make_protocol(mod)
|
|
proto.data_received(_connect_packet())
|
|
run_with_timeout(proto.data_received, data)
|