393 lines
11 KiB
Python
393 lines
11 KiB
Python
"""Known-host OCF multicast responder-port discovery tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from smartthings_local.protocol.coap import (
|
|
ACCEPT,
|
|
TYPE_CON,
|
|
TYPE_NON,
|
|
URI_PATH,
|
|
URI_QUERY,
|
|
build_coap,
|
|
parse_coap,
|
|
)
|
|
from smartthings_local.protocol.ocf_multicast import (
|
|
_OCF_MULTICAST_GROUP,
|
|
OcfResponderPortDiscoveryResult,
|
|
discover_ocf_responder_ports,
|
|
)
|
|
|
|
_TARGET = "192.0.2.20"
|
|
_OTHER = "192.0.2.21"
|
|
_INTERFACE = "192.0.2.10"
|
|
|
|
|
|
class _FakeSocket:
|
|
def __init__(self, responder=None):
|
|
self.responder = responder
|
|
self.incoming = []
|
|
self.sent = []
|
|
self.options = []
|
|
self.bound = None
|
|
self.blocking = None
|
|
self.closed = False
|
|
|
|
def setsockopt(self, level, option, value):
|
|
self.options.append((level, option, value))
|
|
|
|
def bind(self, address):
|
|
self.bound = address
|
|
|
|
def setblocking(self, enabled):
|
|
self.blocking = enabled
|
|
|
|
def sendto(self, datagram, destination):
|
|
self.sent.append((datagram, destination))
|
|
if self.responder is not None:
|
|
self.incoming.extend(self.responder(datagram, destination))
|
|
return len(datagram)
|
|
|
|
def recvfrom(self, _size):
|
|
return self.incoming.pop(0)
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
class _FakeSelector:
|
|
def __init__(self, active):
|
|
self.active = active
|
|
self.closed = False
|
|
|
|
def register(self, *_args):
|
|
return None
|
|
|
|
def unregister(self, *_args):
|
|
return None
|
|
|
|
def select(self, _timeout):
|
|
if not self.active.incoming:
|
|
return []
|
|
return [(SimpleNamespace(fileobj=self.active), selectors_event_read())]
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def selectors_event_read():
|
|
return 1
|
|
|
|
|
|
def _response(
|
|
datagram, _destination=None, *, host=_TARGET, port=43123, message_type=TYPE_NON
|
|
):
|
|
_mtype, _code, mid, token, _options, _payload = parse_coap(datagram)
|
|
response_mid = mid if message_type != TYPE_CON else (mid + 1) & 0xFFFF
|
|
return [
|
|
(
|
|
build_coap(message_type, 0x45, response_mid, token, [], b"directory"),
|
|
(host, port),
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def patch_socket(monkeypatch):
|
|
created = []
|
|
|
|
def install(responder=None):
|
|
active = _FakeSocket(responder)
|
|
selector = _FakeSelector(active)
|
|
created.append((active, selector))
|
|
monkeypatch.setattr(
|
|
"smartthings_local.protocol.ocf_multicast.socket.socket",
|
|
lambda *_args: active,
|
|
)
|
|
monkeypatch.setattr(
|
|
"smartthings_local.protocol.ocf_multicast.selectors.DefaultSelector",
|
|
lambda: selector,
|
|
)
|
|
return active, selector
|
|
|
|
return install
|
|
|
|
|
|
def test_sends_proven_directory_requests_and_filtered_fallback_on_interface(
|
|
patch_socket,
|
|
):
|
|
active, selector = patch_socket(_response)
|
|
|
|
result = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=1,
|
|
)
|
|
|
|
assert result.ports == (43123,)
|
|
assert result.attempts == 3
|
|
assert result.responses == 3
|
|
assert active.bound == (_INTERFACE, 0)
|
|
assert active.blocking is False
|
|
assert active.closed
|
|
assert selector.closed
|
|
assert all(
|
|
destination == (_OCF_MULTICAST_GROUP, 5683) for _, destination in active.sent
|
|
)
|
|
|
|
requests = [parse_coap(datagram) for datagram, _ in active.sent]
|
|
assert all(
|
|
[value for number, value in item[4] if number == URI_PATH] == [b"oic", b"res"]
|
|
for item in requests
|
|
)
|
|
queries = [
|
|
[value for number, value in item[4] if number == URI_QUERY] for item in requests
|
|
]
|
|
assert queries == [[], [], [b"rt=oic.r.doxm"]]
|
|
accepts = [
|
|
[value for number, value in item[4] if number == ACCEPT] for item in requests
|
|
]
|
|
assert accepts == [[b"\x27\x10"], [b"\x3c"], [b"\x3c"]]
|
|
assert [value for number, value in requests[0][4] if number == 2049] == [
|
|
b"\x08\x00"
|
|
]
|
|
assert [value for number, value in requests[1][4] if number == 2049] == []
|
|
assert [value for number, value in requests[2][4] if number == 2049] == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("accepted_accept", "accepted_query"),
|
|
(
|
|
(b"\x27\x10", ()),
|
|
(b"\x3c", ()),
|
|
(b"\x3c", (b"rt=oic.r.doxm",)),
|
|
),
|
|
)
|
|
def test_each_directory_request_profile_can_find_the_responder(
|
|
patch_socket, accepted_accept, accepted_query
|
|
):
|
|
def responder(datagram, destination):
|
|
_mtype, _code, _mid, _token, options, _payload = parse_coap(datagram)
|
|
accept = next(value for number, value in options if number == ACCEPT)
|
|
query = tuple(value for number, value in options if number == URI_QUERY)
|
|
if (accept, query) != (accepted_accept, accepted_query):
|
|
return []
|
|
return _response(datagram, destination)
|
|
|
|
patch_socket(responder)
|
|
result = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=1,
|
|
)
|
|
|
|
assert result.ports == (43123,)
|
|
assert result.responses == 1
|
|
|
|
|
|
def test_accepts_only_token_correlated_content_from_the_target(patch_socket):
|
|
def responder(datagram, _destination):
|
|
responses = _response(datagram)
|
|
_mtype, _code, mid, token, _options, _payload = parse_coap(datagram)
|
|
responses.extend(
|
|
[
|
|
(build_coap(TYPE_NON, 0x45, mid, b"wrong", [], b"x"), (_TARGET, 49999)),
|
|
(build_coap(TYPE_NON, 0x44, mid, token, [], b"x"), (_TARGET, 49998)),
|
|
(build_coap(TYPE_NON, 0x45, mid, token, [], b"x"), (_OTHER, 49997)),
|
|
(build_coap(TYPE_NON, 0x45, mid, token, [], b""), (_TARGET, 49996)),
|
|
]
|
|
)
|
|
return responses
|
|
|
|
patch_socket(responder)
|
|
result = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=1,
|
|
)
|
|
|
|
assert result.ports == (43123,)
|
|
assert result.responses == 3
|
|
|
|
|
|
def test_ignores_malformed_and_oversized_datagrams(patch_socket):
|
|
def responder(datagram, _destination):
|
|
valid = _response(datagram)
|
|
invalid_version = bytes([valid[0][0][0] & 0x3F]) + valid[0][0][1:]
|
|
invalid_token_length = bytes([0x49]) + valid[0][0][1:]
|
|
return [
|
|
(b"\x40", (_TARGET, 49999)),
|
|
(invalid_version, (_TARGET, 49998)),
|
|
(invalid_token_length, (_TARGET, 49997)),
|
|
(b"x" * 8193, (_TARGET, 49996)),
|
|
*valid,
|
|
]
|
|
|
|
patch_socket(responder)
|
|
result = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=1,
|
|
)
|
|
|
|
assert result.ports == (43123,)
|
|
assert result.responses == 3
|
|
|
|
|
|
def test_acknowledges_confirmable_responses(patch_socket):
|
|
active, _selector = patch_socket(
|
|
lambda datagram, _destination: _response(datagram, message_type=TYPE_CON)
|
|
)
|
|
|
|
result = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=1,
|
|
)
|
|
|
|
assert result.found
|
|
acknowledgements = [
|
|
(parse_coap(datagram), destination)
|
|
for datagram, destination in active.sent
|
|
if parse_coap(datagram)[0] == 2
|
|
]
|
|
assert len(acknowledgements) == 3
|
|
assert all(item[0][1] == 0 and item[0][3] == b"" for item in acknowledgements)
|
|
assert all(
|
|
destination == (_TARGET, 43123) for _item, destination in acknowledgements
|
|
)
|
|
|
|
|
|
def test_collects_a_small_bounded_candidate_set(patch_socket):
|
|
response_number = 0
|
|
|
|
def responder(datagram, _destination):
|
|
nonlocal response_number
|
|
port = 40000 + response_number % 8
|
|
response_number += 1
|
|
return _response(datagram, port=port)
|
|
|
|
patch_socket(responder)
|
|
result = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=4,
|
|
)
|
|
|
|
assert result.ports == tuple(range(40000, 40008))
|
|
assert result.error_code is None
|
|
|
|
|
|
def test_fails_closed_when_too_many_distinct_ports_answer(patch_socket):
|
|
next_port = 40000
|
|
|
|
def responder(datagram, _destination):
|
|
nonlocal next_port
|
|
responses = [
|
|
*_response(datagram, port=next_port),
|
|
*_response(datagram, port=next_port + 1),
|
|
]
|
|
next_port += 2
|
|
return responses
|
|
|
|
patch_socket(responder)
|
|
result = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=4,
|
|
)
|
|
|
|
assert result.ports == ()
|
|
assert result.responses == 24
|
|
assert result.error_code == "ambiguous_response"
|
|
|
|
|
|
def test_no_response_and_interface_failures_are_fixed_results(
|
|
patch_socket, monkeypatch
|
|
):
|
|
active, _selector = patch_socket()
|
|
no_response = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
rounds=1,
|
|
)
|
|
assert no_response == OcfResponderPortDiscoveryResult(
|
|
ports=(),
|
|
attempts=3,
|
|
responses=0,
|
|
error_code="no_response",
|
|
)
|
|
assert active.closed
|
|
|
|
class BrokenSocket:
|
|
def setsockopt(self, *_args):
|
|
raise OSError("synthetic")
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"smartthings_local.protocol.ocf_multicast.socket.socket",
|
|
lambda *_args: BrokenSocket(),
|
|
)
|
|
unavailable = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
)
|
|
assert unavailable.error_code == "interface_unavailable"
|
|
assert unavailable.attempts == 0
|
|
|
|
monkeypatch.setattr(
|
|
"smartthings_local.protocol.ocf_multicast.selectors.DefaultSelector",
|
|
lambda: (_ for _ in ()).throw(OSError("synthetic")),
|
|
)
|
|
unavailable = discover_ocf_responder_ports(
|
|
_TARGET,
|
|
interface_address=_INTERFACE,
|
|
)
|
|
assert unavailable.error_code == "interface_unavailable"
|
|
assert unavailable.attempts == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("kwargs", "exception"),
|
|
[
|
|
({"target_address": 123}, TypeError),
|
|
({"target_address": "not-an-address"}, ValueError),
|
|
({"target_address": _OCF_MULTICAST_GROUP}, ValueError),
|
|
({"interface_address": "0.0.0.0"}, ValueError),
|
|
({"discovery_port": True}, TypeError),
|
|
({"discovery_port": 0}, ValueError),
|
|
({"timeout": float("nan")}, ValueError),
|
|
({"rounds": 0}, ValueError),
|
|
({"rounds": 5}, ValueError),
|
|
],
|
|
)
|
|
def test_rejects_invalid_options_without_opening_a_socket(
|
|
monkeypatch, kwargs, exception
|
|
):
|
|
values = {
|
|
"target_address": _TARGET,
|
|
"interface_address": _INTERFACE,
|
|
**kwargs,
|
|
}
|
|
socket_factory = pytest.fail
|
|
monkeypatch.setattr(
|
|
"smartthings_local.protocol.ocf_multicast.socket.socket",
|
|
socket_factory,
|
|
)
|
|
with pytest.raises(exception):
|
|
discover_ocf_responder_ports(**values)
|
|
|
|
|
|
def test_result_repr_omits_ports_and_addresses():
|
|
result = OcfResponderPortDiscoveryResult(ports=(43123,), attempts=2, responses=1)
|
|
|
|
rendered = repr(result)
|
|
assert "43123" not in rendered
|
|
assert _TARGET not in rendered
|
|
assert "port_count=1" in rendered
|