"""Unit tests for zigbee_service: parser and hierarchy builder.""" from __future__ import annotations import json from typing import Any from unittest.mock import patch import aiomqtt # noqa: F401 import pytest from app.services.zigbee_service import ( _find_parent_router, _z2m_type_to_homelable, fetch_networkmap, parse_networkmap, ) from app.services.zigbee_service import ( test_mqtt_connection as _test_mqtt_connection, ) # --------------------------------------------------------------------------- # Helper builders — real Z2M `bridge/response/networkmap` shape # (data.value.nodes + data.value.links) # --------------------------------------------------------------------------- def _make_node( ieee: str, device_type: str = "EndDevice", friendly_name: str | None = None, model: str | None = None, vendor: str | None = None, ) -> dict[str, Any]: entry: dict[str, Any] = { "ieeeAddr": ieee, "type": device_type, "friendlyName": friendly_name or ieee, } if model or vendor: entry["definition"] = {"model": model, "vendor": vendor} return entry def _make_link(source_ieee: str, target_ieee: str, lqi: int = 200) -> dict[str, Any]: return { "source": {"ieeeAddr": source_ieee}, "target": {"ieeeAddr": target_ieee}, "lqi": lqi, } def _wrap(nodes: list[dict[str, Any]], links: list[dict[str, Any]] | None = None) -> dict[str, Any]: return { "data": { "type": "raw", "routes": False, "value": {"nodes": nodes, "links": links or []}, }, "status": "ok", } # --------------------------------------------------------------------------- # _z2m_type_to_homelable # --------------------------------------------------------------------------- class TestZ2mTypeToHomelable: def test_coordinator(self) -> None: assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator" def test_router(self) -> None: assert _z2m_type_to_homelable("Router") == "zigbee_router" def test_enddevice(self) -> None: assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice" def test_unknown_defaults_to_enddevice(self) -> None: assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice" # --------------------------------------------------------------------------- # parse_networkmap # --------------------------------------------------------------------------- class TestParseNetworkmap: def test_empty_payload(self) -> None: nodes, edges = parse_networkmap({}) assert nodes == [] assert edges == [] def test_empty_value(self) -> None: nodes, edges = parse_networkmap(_wrap([], [])) assert nodes == [] assert edges == [] def test_coordinator_only(self) -> None: payload = _wrap([_make_node("0x0000000000000000", "Coordinator", "Coordinator")]) nodes, edges = parse_networkmap(payload) assert len(nodes) == 1 assert nodes[0]["type"] == "zigbee_coordinator" assert nodes[0]["ieee_address"] == "0x0000000000000000" assert edges == [] def test_coordinator_router_enddevice(self) -> None: coord_ieee = "0x0000000000000000" router_ieee = "0x0000000000000001" end_ieee = "0x0000000000000002" payload = _wrap( nodes=[ _make_node(coord_ieee, "Coordinator", "Coordinator"), _make_node(router_ieee, "Router", "my_router"), _make_node(end_ieee, "EndDevice"), ], links=[ _make_link(coord_ieee, router_ieee), _make_link(router_ieee, end_ieee), ], ) nodes, edges = parse_networkmap(payload) node_by_id = {n["id"]: n for n in nodes} assert coord_ieee in node_by_id assert router_ieee in node_by_id assert end_ieee in node_by_id assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator" assert node_by_id[router_ieee]["type"] == "zigbee_router" assert node_by_id[end_ieee]["type"] == "zigbee_enddevice" # Parent hierarchy assert node_by_id[router_ieee]["parent_id"] == coord_ieee assert node_by_id[end_ieee]["parent_id"] == router_ieee assert len(edges) == 2 def test_no_duplicate_nodes(self) -> None: ieee = "0x0000000000000001" payload = _wrap( nodes=[_make_node(ieee, "Router"), _make_node(ieee, "Router")], ) nodes, _ = parse_networkmap(payload) assert len(nodes) == 1 def test_edges_built_correctly(self) -> None: coord = "0x0000" router = "0x0001" payload = _wrap( nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")], links=[_make_link(coord, router)], ) _, edges = parse_networkmap(payload) assert len(edges) == 1 assert edges[0]["source"] == coord assert edges[0]["target"] == router def test_friendly_name_used_as_label(self) -> None: payload = _wrap([_make_node("0xABCD", "EndDevice", "Living Room Sensor")]) nodes, _ = parse_networkmap(payload) assert nodes[0]["label"] == "Living Room Sensor" def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None: coord = "0x0000" end = "0x0003" payload = _wrap([_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")]) nodes, _ = parse_networkmap(payload) end_node = next(n for n in nodes if n["id"] == end) assert end_node["parent_id"] == coord def test_missing_ieee_skipped(self) -> None: payload = _wrap([{"type": "EndDevice"}]) # no ieeeAddr nodes, edges = parse_networkmap(payload) assert nodes == [] assert edges == [] def test_lqi_propagated_from_link_to_target_node(self) -> None: coord = "0x0000" end = "0x0001" payload = _wrap( nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")], links=[_make_link(coord, end, lqi=180)], ) nodes, _ = parse_networkmap(payload) end_node = next(n for n in nodes if n["id"] == end) assert end_node["lqi"] == 180 def test_definition_model_and_vendor_extracted(self) -> None: payload = _wrap([ _make_node("0xAA", "EndDevice", "Sensor", model="WSDCGQ11LM", vendor="Aqara"), ]) nodes, _ = parse_networkmap(payload) assert nodes[0]["model"] == "WSDCGQ11LM" assert nodes[0]["vendor"] == "Aqara" def test_legacy_shape_without_value_wrapper(self) -> None: """Some Z2M variants put nodes/links directly under data.""" payload = {"data": {"nodes": [_make_node("0x01", "Coordinator")], "links": []}} nodes, _ = parse_networkmap(payload) assert len(nodes) == 1 assert nodes[0]["type"] == "zigbee_coordinator" def test_routes_bool_is_ignored(self) -> None: """`routes: false` echo from the request must not crash the parser.""" payload = {"data": {"routes": False, "type": "raw", "value": {"nodes": [], "links": []}}} nodes, edges = parse_networkmap(payload) assert nodes == [] assert edges == [] def test_malformed_nodes_not_list_raises(self) -> None: with pytest.raises(ValueError, match="not a list"): parse_networkmap({"data": {"value": {"nodes": "oops", "links": []}}}) def test_link_to_unknown_node_dropped(self) -> None: payload = _wrap( nodes=[_make_node("0x01", "Coordinator")], links=[_make_link("0x01", "0xDEAD")], # 0xDEAD not in nodes ) _, edges = parse_networkmap(payload) assert edges == [] def test_bidirectional_links_yield_single_edge(self) -> None: """Z2M links are bidirectional — every pair appears twice. The output must collapse to a single parent→child edge (no back-link, no dup).""" coord = "0x0000" router = "0x0001" payload = _wrap( nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")], links=[ _make_link(coord, router), _make_link(router, coord), # reverse direction ], ) _, edges = parse_networkmap(payload) assert edges == [{"source": coord, "target": router}] def test_router_mesh_siblings_dropped(self) -> None: """Router↔router mesh paths in `links` must NOT produce sibling edges in the final tree. Each router gets exactly one edge from coordinator.""" coord = "0x0000" r1 = "0x0001" r2 = "0x0002" payload = _wrap( nodes=[ _make_node(coord, "Coordinator"), _make_node(r1, "Router"), _make_node(r2, "Router"), ], links=[ _make_link(coord, r1), _make_link(coord, r2), _make_link(r1, r2), # mesh sibling — must be dropped _make_link(r2, r1), ], ) _, edges = parse_networkmap(payload) pairs = {(e["source"], e["target"]) for e in edges} assert pairs == {(coord, r1), (coord, r2)} def test_coordinator_has_no_incoming_edge(self) -> None: coord = "0x0000" end = "0x0001" payload = _wrap( nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")], links=[_make_link(end, coord)], # back-edge from end to coord ) _, edges = parse_networkmap(payload) # No edge should target the coordinator assert all(e["target"] != coord for e in edges) assert edges == [{"source": coord, "target": end}] # --------------------------------------------------------------------------- # _find_parent_router # --------------------------------------------------------------------------- class TestFindParentRouter: def test_finds_router_as_source(self) -> None: router_ids = {"r1"} edges = [{"source": "r1", "target": "e1"}] assert _find_parent_router("e1", router_ids, edges) == "r1" def test_finds_router_as_target(self) -> None: router_ids = {"r1"} edges = [{"source": "e1", "target": "r1"}] assert _find_parent_router("e1", router_ids, edges) == "r1" def test_returns_none_when_no_router(self) -> None: router_ids: set[str] = set() edges = [{"source": "e1", "target": "e2"}] assert _find_parent_router("e1", router_ids, edges) is None def test_returns_none_empty_edges(self) -> None: assert _find_parent_router("e1", {"r1"}, []) is None # --------------------------------------------------------------------------- # fetch_networkmap (integration-style with mocked aiomqtt) # --------------------------------------------------------------------------- SAMPLE_RESPONSE_PAYLOAD = { "data": { "type": "raw", "routes": False, "value": { "nodes": [ { "ieeeAddr": "0x00000000", "type": "Coordinator", "friendlyName": "Coordinator", }, { "ieeeAddr": "0x00000001", "type": "Router", "friendlyName": "router_1", }, ], "links": [ { "source": {"ieeeAddr": "0x00000000"}, "target": {"ieeeAddr": "0x00000001"}, "lqi": 230, } ], }, }, "status": "ok", } @pytest.mark.asyncio async def test_fetch_networkmap_success() -> None: """fetch_networkmap returns parsed nodes/edges when MQTT responds normally.""" class _FakeMessage: topic = "zigbee2mqtt/bridge/response/networkmap" payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode() _yielded = False 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() with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception nodes, edges = await fetch_networkmap( mqtt_host="localhost", mqtt_port=1883, base_topic="zigbee2mqtt", ) assert any(n["type"] == "zigbee_coordinator" for n in nodes) assert any(n["type"] == "zigbee_router" for n in nodes) @pytest.mark.asyncio async def test_fetch_networkmap_connection_error() -> None: """fetch_networkmap raises ConnectionError when MQTT broker is unreachable.""" class _FakeClient: async def __aenter__(self): raise Exception("Connection refused") async def __aexit__(self, *_): pass with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception with pytest.raises(ConnectionError): await fetch_networkmap( mqtt_host="bad-host", mqtt_port=1883, base_topic="zigbee2mqtt", ) @pytest.mark.asyncio async def test_test_mqtt_connection_success() -> None: class _FakeClient: async def __aenter__(self): return self async def __aexit__(self, *_): pass with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception result = await _test_mqtt_connection("localhost", 1883) assert result is True @pytest.mark.asyncio async def test_test_mqtt_connection_failure() -> None: class _FakeClient: async def __aenter__(self): raise Exception("refused") async def __aexit__(self, *_): pass with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception with pytest.raises(ConnectionError): await _test_mqtt_connection("bad-host", 1883) # --------------------------------------------------------------------------- # TLS context # --------------------------------------------------------------------------- import ssl # noqa: E402 from app.services.zigbee_service import _build_tls_context # noqa: E402 def test_build_tls_context_secure_verifies_cert() -> None: ctx = _build_tls_context(insecure=False) assert ctx.check_hostname is True assert ctx.verify_mode == ssl.CERT_REQUIRED def test_build_tls_context_insecure_disables_verification() -> None: ctx = _build_tls_context(insecure=True) assert ctx.check_hostname is False assert ctx.verify_mode == ssl.CERT_NONE @pytest.mark.asyncio async def test_test_mqtt_connection_passes_tls_context() -> None: class _FakeClient: async def __aenter__(self): return self async def __aexit__(self, *_): pass with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception await _test_mqtt_connection("host", 8883, tls=True) kwargs = mock_aiomqtt.Client.call_args.kwargs assert kwargs["tls_context"] is not None assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED @pytest.mark.asyncio async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None: class _FakeClient: async def __aenter__(self): return self async def __aexit__(self, *_): pass with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception await _test_mqtt_connection("host", 1883, tls=False) assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None @pytest.mark.asyncio async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None: class _FakeClient: async def __aenter__(self): return self async def __aexit__(self, *_): pass with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True) ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"] assert ctx.verify_mode == ssl.CERT_NONE assert ctx.check_hostname is False # --------------------------------------------------------------------------- # Sanitize MQTT errors # --------------------------------------------------------------------------- from app.services.zigbee_service import _sanitize_mqtt_error # noqa: E402 def test_sanitize_auth_error_does_not_leak_credentials() -> None: msg = _sanitize_mqtt_error( Exception("Not authorized: bad username or password for user=admin pwd=secret") ) assert msg == "Authentication failed" assert "admin" not in msg 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_failure_strips_host() -> None: msg = _sanitize_mqtt_error( Exception("[Errno 8] nodename nor servname provided, or not known: broker.internal.lan") ) assert msg == "Broker hostname could not be resolved" assert "broker.internal.lan" not in msg def test_sanitize_tls_error() -> None: assert _sanitize_mqtt_error( Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") ) == "TLS handshake failed" def test_sanitize_unknown_falls_back_to_generic() -> None: msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker:1883 weird state")) assert msg == "MQTT connection failed" assert "hunter2" not in msg assert "admin" not in msg @pytest.mark.asyncio async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> None: class _FakeClient: async def __aenter__(self): raise Exception("Not authorized: rejected mqtt://admin:hunter2@host") async def __aexit__(self, *_): pass with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: mock_aiomqtt.Client.return_value = _FakeClient() mock_aiomqtt.MqttError = Exception with pytest.raises(ConnectionError) as ei: await fetch_networkmap( mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt" ) msg = str(ei.value) assert "hunter2" not in msg assert "admin" not in msg assert msg == "Authentication failed"