Files
homelable/backend/tests/test_mqtt_common.py
Pouzor 322600f1c9 fix(zigbee): stop canvas imports dying on a proxy read timeout
A Zigbee2MQTT networkmap on a 200+ device mesh takes minutes to build.
Two separate failures fell out of that:

- POST /zigbee/import held the HTTP request open for the whole MQTT
  round-trip, so any reverse proxy in front of the API cut it first
  (Cloudflare returns a 524 at 120 s) and the browser never saw the map.
  It now registers a job, fetches in the background and answers 202; the
  client polls GET /zigbee/import/{job_id} until the payload is ready.
  Job results are transient and live in memory with a 15 min TTL — the
  same single-worker assumption the scheduler already makes. A failed
  fetch replays the status the synchronous route used to raise, so a bad
  broker is still a 502 and a slow mesh still a 504.

- The networkmap wait was hard-coded at 300 s with no way to raise it.
  It now reads ZIGBEE_NETWORKMAP_TIMEOUT, and the shared MQTT round-trip
  used by the Z-Wave import reads MQTT_RESPONSE_TIMEOUT. Both default to
  300 s, fall back to that if misconfigured to a non-positive value, and
  name themselves in the timeout message.

Also corrects the route and doc claims that the wait was 60 s.

The /import tests changed with the contract they cover, not to pass.

Fixes #380

ha-relevant: yes
2026-08-31 11:26:45 +02:00

284 lines
8.5 KiB
Python

"""Unit tests for the shared MQTT helpers in mqtt_common."""
from __future__ import annotations
import asyncio
import json
import ssl
from unittest.mock import patch
import pytest
from app.core.config import settings
from app.services.mqtt_common import (
_RESPONSE_TIMEOUT,
_build_tls_context,
_sanitize_mqtt_error,
request_response,
)
from app.services.mqtt_common import test_connection as _test_connection
# ---------------------------------------------------------------------------
# _sanitize_mqtt_error — never leak credentials
# ---------------------------------------------------------------------------
def test_sanitize_auth_error() -> None:
msg = _sanitize_mqtt_error(Exception("Not authorized: bad username for user=admin pwd=secret"))
assert msg == "Authentication failed"
assert "secret" not in msg
def test_sanitize_refused() -> None:
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
def test_sanitize_dns() -> None:
msg = _sanitize_mqtt_error(Exception("nodename nor servname provided: broker.lan"))
assert msg == "Broker hostname could not be resolved"
assert "broker.lan" not in msg
def test_sanitize_tls() -> None:
assert _sanitize_mqtt_error(
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
) == "TLS handshake failed"
def test_sanitize_timeout() -> None:
assert _sanitize_mqtt_error(Exception("operation timed out")) == "Connection to broker timed out"
def test_sanitize_unknown_falls_back() -> None:
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker weird"))
assert msg == "MQTT connection failed"
assert "hunter2" not in msg
# ---------------------------------------------------------------------------
# _build_tls_context
# ---------------------------------------------------------------------------
def test_tls_secure_verifies() -> None:
ctx = _build_tls_context(insecure=False)
assert ctx.check_hostname is True
assert ctx.verify_mode == ssl.CERT_REQUIRED
def test_tls_insecure_disables_verification() -> None:
ctx = _build_tls_context(insecure=True)
assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE
# ---------------------------------------------------------------------------
# request_response (mocked aiomqtt)
# ---------------------------------------------------------------------------
_SAMPLE = {"success": True, "result": []}
def _fake_client_factory(topic: str, payload: dict):
class _FakeMessage:
_yielded = False
def __init__(self) -> None:
self.topic = topic
self.payload = json.dumps(payload).encode()
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return self
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def subscribe(self, _topic: str) -> None:
pass
async def publish(self, _topic: str, _payload: str) -> None:
pass
@property
def messages(self):
return _FakeMessage()
return _FakeClient
@pytest.mark.asyncio
async def test_request_response_success() -> None:
topic = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
mock_aiomqtt.MqttError = Exception
out = await request_response(
"localhost", 1883, "req/topic", topic, {"args": []}
)
assert out == _SAMPLE
@pytest.mark.asyncio
async def test_request_response_connection_error() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("Connection refused")
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await request_response("bad", 1883, "req", "resp", {})
@pytest.mark.asyncio
async def test_request_response_passes_tls_context() -> None:
topic = "resp"
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
mock_aiomqtt.MqttError = Exception
await request_response("h", 8883, "req", topic, {}, tls=True, tls_insecure=True)
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
assert ctx.verify_mode == ssl.CERT_NONE
@pytest.mark.asyncio
async def test_test_connection_success() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
assert await _test_connection("localhost", 1883) is True
@pytest.mark.asyncio
async def test_test_connection_failure() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("refused")
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await _test_connection("bad", 1883)
# ---------------------------------------------------------------------------
# Response timeout — configurable via MQTT_RESPONSE_TIMEOUT (issue #380)
# ---------------------------------------------------------------------------
class _SilentClient:
"""An MQTT client that connects but never delivers the response message."""
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def subscribe(self, *_a, **_kw) -> None:
pass
async def publish(self, *_a, **_kw) -> None:
pass
@property
def messages(self):
async def _never():
await asyncio.Event().wait()
yield # pragma: no cover
return _never()
class _NeverMqttError(Exception):
"""Stand-in for aiomqtt.MqttError that no test path actually raises."""
async def _round_trip(**kwargs):
return await request_response(
mqtt_host="host",
mqtt_port=1883,
request_topic="req",
response_topic="res",
request_payload={},
**kwargs,
)
@pytest.mark.asyncio
async def test_request_response_uses_settings_timeout(monkeypatch) -> None:
monkeypatch.setattr(settings, "mqtt_response_timeout", 0.01)
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _SilentClient()
mock_aiomqtt.MqttError = _NeverMqttError
with pytest.raises(TimeoutError) as ei:
await _round_trip()
msg = str(ei.value)
assert "0.01s" in msg
assert "MQTT_RESPONSE_TIMEOUT" in msg
@pytest.mark.asyncio
async def test_request_response_explicit_timeout_overrides_settings(monkeypatch) -> None:
monkeypatch.setattr(settings, "mqtt_response_timeout", 999)
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _SilentClient()
mock_aiomqtt.MqttError = _NeverMqttError
with pytest.raises(TimeoutError) as ei:
await _round_trip(response_timeout=0.01)
assert "0.01s" in str(ei.value)
@pytest.mark.asyncio
async def test_request_response_non_positive_setting_falls_back(monkeypatch) -> None:
"""A misconfigured 0 must not mean 'give up immediately'."""
monkeypatch.setattr(settings, "mqtt_response_timeout", 0)
captured: dict[str, float] = {}
async def _fake_wait_for(awaitable, timeout):
captured["timeout"] = timeout
awaitable.close()
raise asyncio.TimeoutError
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _SilentClient()
mock_aiomqtt.MqttError = _NeverMqttError
with (
patch("app.services.mqtt_common.asyncio.wait_for", _fake_wait_for),
pytest.raises(TimeoutError),
):
await _round_trip()
assert captured["timeout"] == _RESPONSE_TIMEOUT