diff --git a/kolibri/core/discovery/hooks.py b/kolibri/core/discovery/hooks.py index 1b82e22a61e..cdf6d81f492 100644 --- a/kolibri/core/discovery/hooks.py +++ b/kolibri/core/discovery/hooks.py @@ -42,3 +42,43 @@ def on_renew(self, instance, network_locations): :type network_locations: kolibri.core.discovery.models.NetworkLocation[] """ pass + + +@define_hook(only_one_registered=True) +class NetworkDiscoveryHook(KolibriHook): + """ + A single-registration transport for network discovery. The default zeroconf + transport is registered with `as_default=True`; a platform may override it. + """ + + @abstractmethod + def register(self, instance): + """Start advertising `instance` on this transport.""" + + @abstractmethod + def update(self, instance, on_rebind): + """ + Re-advertise `instance` (when not None) and apply any transport rebind. + + Call `on_rebind()` before performing a rebind, not after: the backend + cycles its broadcast id there, and a peer rediscovered by the rebind + has to be enqueued under the incoming id. + """ + + @abstractmethod + def unregister(self): + """Stop advertising our instance.""" + + @abstractmethod + def start_listening(self, on_add, on_update, on_remove, is_known): + """ + Begin discovering peers, dispatching KolibriInstances to the callbacks. + + `is_known(name)` reports whether the backend already has a broadcasting + instance cached for a service name, so the transport can skip querying + it again. + """ + + @abstractmethod + def stop_listening(self): + """Stop discovering peers and release transport resources.""" diff --git a/kolibri/core/discovery/kolibri_plugin.py b/kolibri/core/discovery/kolibri_plugin.py new file mode 100644 index 00000000000..89913f1af36 --- /dev/null +++ b/kolibri/core/discovery/kolibri_plugin.py @@ -0,0 +1,10 @@ +from kolibri.core.discovery.hooks import NetworkDiscoveryHook +from kolibri.core.discovery.utils.network.zeroconf_transport import ( + ZeroconfNetworkDiscovery, +) +from kolibri.plugins.hooks import register_hook + + +@register_hook(as_default=True) +class ZeroconfNetworkDiscoveryHook(ZeroconfNetworkDiscovery, NetworkDiscoveryHook): + """The built-in transport; a platform may override it with a non-default hook.""" diff --git a/kolibri/core/discovery/test/test_discovery_hooks.py b/kolibri/core/discovery/test/test_discovery_hooks.py new file mode 100644 index 00000000000..fff7899155e --- /dev/null +++ b/kolibri/core/discovery/test/test_discovery_hooks.py @@ -0,0 +1,63 @@ +import pytest + +from kolibri.core.discovery.hooks import NetworkDiscoveryHook +from kolibri.core.discovery.utils.network.zeroconf_transport import ( + ZeroconfNetworkDiscovery, +) +from kolibri.plugins.hooks import HookSingleInstanceError +from kolibri.plugins.hooks import register_hook + + +class ConcreteDiscoveryHook(NetworkDiscoveryHook): + """ + Test-only concrete subclass supplying a stub body for all five abstract + methods so SingletonMeta can instantiate the singleton when the hook is + added to the registries. + """ + + def register(self, instance): + pass + + def update(self, instance, on_rebind): + pass + + def unregister(self): + pass + + def start_listening(self, on_add, on_update, on_remove, is_known): + pass + + def stop_listening(self): + pass + + +@pytest.fixture +def register_discovery_hook(): + """Registers fresh hook subclasses against a clean registry.""" + # swap in an empty registry, and restore the real one — which holds the + # default zeroconf transport — afterwards + registered_hooks = NetworkDiscoveryHook._registered_hooks + NetworkDiscoveryHook._registered_hooks = {} + + def register(name): + Impl = type( + name, (ConcreteDiscoveryHook,), {"__module__": "test.kolibri_plugin"} + ) + Hook = register_hook(Impl) + Hook.add_hook_to_registries() + return Hook + + yield register + + NetworkDiscoveryHook._registered_hooks = registered_hooks + + +def test_only_one_transport_may_be_registered(register_discovery_hook): + register_discovery_hook("OverrideImpl") + with pytest.raises(HookSingleInstanceError): + register_discovery_hook("OtherOverrideImpl") + + +def test_default_transport_is_zeroconf(): + """`kolibri.core.discovery`'s plugin registers zeroconf as the default.""" + assert isinstance(NetworkDiscoveryHook.registered_hook, ZeroconfNetworkDiscovery) diff --git a/kolibri/core/discovery/test/test_local_hostnames.py b/kolibri/core/discovery/test/test_local_hostnames.py deleted file mode 100644 index d4277b680b5..00000000000 --- a/kolibri/core/discovery/test/test_local_hostnames.py +++ /dev/null @@ -1,27 +0,0 @@ -import mock -from django.test import TestCase - -from ..utils.network.broadcast import KolibriBroadcast -from ..utils.network.broadcast import KolibriInstance -from ..utils.network.local_hostnames import LocalHostnameListener - -MOCK_INTERFACE_IP = "111.222.111.222" -MOCK_PORT = 555 -MOCK_ID = "abba" -LOCAL_HOSTNAMES_MODULE = "kolibri.core.discovery.utils.network.local_hostnames." - - -class LocalHostnameListenerTestCase(TestCase): - databases = "__all__" - - def setUp(self): - super().setUp() - self.instance = KolibriInstance(MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT) - self.broadcast = KolibriBroadcast(instance=self.instance) - self.listener = LocalHostnameListener(self.broadcast) - - @mock.patch(LOCAL_HOSTNAMES_MODULE + "sync_local_hostnames.enqueue") - def test_update_local_names(self, mock_enqueue): - hostnames = ["kolibri.local", "tonyslaptop.local"] - self.listener.update_local_names(hostnames) - mock_enqueue.assert_called_once_with(args=(hostnames,)) diff --git a/kolibri/core/discovery/test/test_network_backend.py b/kolibri/core/discovery/test/test_network_backend.py new file mode 100644 index 00000000000..09d11c294e0 --- /dev/null +++ b/kolibri/core/discovery/test/test_network_backend.py @@ -0,0 +1,401 @@ +import mock +from django.test import SimpleTestCase + +from kolibri.core.discovery.hooks import NetworkDiscoveryHook +from kolibri.core.discovery.utils.network.broadcast import EVENT_ADD_INSTANCE +from kolibri.core.discovery.utils.network.broadcast import EVENT_REGISTER_INSTANCE +from kolibri.core.discovery.utils.network.broadcast import EVENT_REMOVE_INSTANCE +from kolibri.core.discovery.utils.network.broadcast import EVENT_RENEW_INSTANCE +from kolibri.core.discovery.utils.network.broadcast import EVENT_UNREGISTER_INSTANCE +from kolibri.core.discovery.utils.network.broadcast import EVENT_UPDATE_INSTANCE +from kolibri.core.discovery.utils.network.broadcast import KolibriInstance +from kolibri.core.discovery.utils.network.broadcast import NetworkDiscoveryBackend +from kolibri.core.discovery.utils.network.broadcast import SERVICE_TTL +from kolibri.core.discovery.utils.network.search import NetworkLocationListener +from kolibri.plugins.hooks import register_hook + +MOCK_INTERFACE_IP = "111.222.111.222" +MOCK_PORT = 555 +SEARCH_MODULE = "kolibri.core.discovery.utils.network.search." + + +def _register_transport(name="FakeTransport", as_default=False): + """ + Registers a transport that records the calls the backend makes on it, and + returns the singleton instance the backend will resolve. Each call defines + a fresh subclass, so every registration gets its own recorder — under a + distinct `name`, as the registry keys on it and would otherwise evict the + earlier registration rather than rank the two. + """ + + class FakeTransport(NetworkDiscoveryHook): + def __init__(self): + self.calls = [] + self.rebound = False + self.is_known = None + self.on_register = None + + def register(self, instance): + self.calls.append("register") + if self.on_register is not None: + self.on_register() + + def update(self, instance, on_rebind): + self.calls.append("update") + if self.rebound: + on_rebind() + self.calls.append("rebind") + + def unregister(self): + self.calls.append("unregister") + + def start_listening(self, on_add, on_update, on_remove, is_known): + self.calls.append("start_listening") + self.is_known = is_known + + def stop_listening(self): + self.calls.append("stop_listening") + + FakeTransport.__name__ = name + FakeTransport.__module__ = "test.kolibri_plugin" + Hook = register_hook(as_default=as_default)(FakeTransport) + Hook.add_hook_to_registries() + return Hook() + + +def _make_instance(instance_id, is_self=False): + instance = KolibriInstance( + instance_id, + ip=MOCK_INTERFACE_IP, + port=MOCK_PORT, + device_info={"instance_id": instance_id}, + ) + instance.is_self = is_self + return instance + + +class NetworkDiscoveryBackendTestCase(SimpleTestCase): + def setUp(self): + super().setUp() + self.instance = _make_instance("abcd") + self.backend = NetworkDiscoveryBackend(instance=self.instance) + # swap in an empty registry so only this test's transports resolve; the + # real one — which holds the default zeroconf transport — is restored in + # tearDown + self.registered_hooks = NetworkDiscoveryHook._registered_hooks + NetworkDiscoveryHook._registered_hooks = {} + + def tearDown(self): + NetworkDiscoveryHook._registered_hooks = self.registered_hooks + super().tearDown() + + def _subscribe(self, event): + listener = mock.Mock() + self.backend.events.subscribe(event, listener) + return listener + + def _start_with_transport(self, rebound=False): + transport = _register_transport(as_default=True) + transport.rebound = rebound + self.backend.start_broadcast() + return transport + + # --- dispatch tests (no transport needed) --- + + @mock.patch(SEARCH_MODULE + "add_dynamic_network_location.enqueue") + def test_on_add_dispatches_to_network_location_listener(self, mock_enqueue): + self.backend.add_listener(NetworkLocationListener) + peer = _make_instance("peer") + self.backend.on_add(peer) + mock_enqueue.assert_called_once() + + @mock.patch(SEARCH_MODULE + "add_dynamic_network_location.enqueue") + @mock.patch(SEARCH_MODULE + "remove_dynamic_network_location.enqueue") + def test_on_remove_dispatches_to_network_location_listener( + self, mock_enqueue, mock_add_enqueue + ): + self.backend.add_listener(NetworkLocationListener) + peer = _make_instance("peer") + peer.set_broadcasting(mock.Mock()) + self.backend.on_add(peer) + self.backend.on_remove(peer.name) + mock_enqueue.assert_called_once() + + def test_leave_rejoin_republishes_add(self): + listener = self._subscribe(EVENT_ADD_INSTANCE) + peer = _make_instance("peer") + peer.set_broadcasting(mock.Mock()) + self.backend.on_add(peer) + self.backend.on_remove(peer.name) + rejoined = _make_instance("peer") + rejoined.set_broadcasting(mock.Mock()) + self.backend.on_add(rejoined) + self.assertEqual(listener.call_count, 2) + + def test_on_add_ignores_self_instance(self): + listener = self._subscribe(EVENT_ADD_INSTANCE) + peer = _make_instance("peer", is_self=True) + self.backend.on_add(peer) + listener.assert_not_called() + self.assertEqual(self.backend.other_instances, {}) + + def test_on_update_publishes_update(self): + listener = self._subscribe(EVENT_UPDATE_INSTANCE) + peer = _make_instance("peer") + self.backend.on_update(peer) + listener.assert_called_once() + + def test_on_update_ignores_self_instance(self): + listener = self._subscribe(EVENT_UPDATE_INSTANCE) + peer = _make_instance("peer", is_self=True) + self.backend.on_update(peer) + listener.assert_not_called() + self.assertEqual(self.backend.other_instances, {}) + + # --- on_update dedup against the cached instance --- + + def _update_cached_peer(self, original, updated): + """ + Seeds `original` in the cache under the service name both instances + share, then dispatches `updated` for that name. + + :return: the EVENT_UPDATE_INSTANCE listener and the cached instance + """ + original.zeroconf_id = updated.zeroconf_id = "peer" + self.backend.other_instances[original.name] = original + listener = self._subscribe(EVENT_UPDATE_INSTANCE) + self.backend.on_update(updated) + return listener, self.backend.other_instances[updated.name] + + def test_on_update__no_change_less_than_TTL(self): + original = _make_instance("peer") + updated = _make_instance("peer") + listener, cached = self._update_cached_peer(original, updated) + listener.assert_not_called() + self.assertIs(original, cached) + + def test_on_update__no_change_more_than_or_equal_to_TTL(self): + original = _make_instance("peer") + updated = _make_instance("peer") + updated.last_seen = original.last_seen + SERVICE_TTL + listener, cached = self._update_cached_peer(original, updated) + listener.assert_called_once_with(updated) + self.assertIs(updated, cached) + + def test_on_update__change_id(self): + original = _make_instance("peer") + original.id = "not the same id" + updated = _make_instance("peer") + listener, cached = self._update_cached_peer(original, updated) + listener.assert_called_once_with(updated) + self.assertIs(updated, cached) + + def test_on_update__change_ip(self): + original = _make_instance("peer") + original.ip = "211.211.16.1" + updated = _make_instance("peer") + listener, cached = self._update_cached_peer(original, updated) + listener.assert_called_once_with(updated) + self.assertIs(updated, cached) + + def test_on_update__change_port(self): + original = _make_instance("peer") + original.port = 2121 + updated = _make_instance("peer") + listener, cached = self._update_cached_peer(original, updated) + listener.assert_called_once_with(updated) + self.assertIs(updated, cached) + + def test_on_update__change_host(self): + original = _make_instance("peer") + original.host = "http://test.com" + updated = _make_instance("peer") + updated.host = "http://test2.com" + listener, cached = self._update_cached_peer(original, updated) + listener.assert_called_once_with(updated) + self.assertIs(updated, cached) + + def test_on_update__change_device_info(self): + original = _make_instance("peer") + original.device_info = {"kolibri_version": "0.15.12"} + updated = _make_instance("peer") + updated.device_info = {"kolibri_version": "0.16.0"} + listener, cached = self._update_cached_peer(original, updated) + listener.assert_called_once_with(updated) + self.assertIs(updated, cached) + + def test_on_update__change_prefix(self): + original = _make_instance("peer") + updated = _make_instance("peer") + updated.prefix = "/kolibri" + listener, cached = self._update_cached_peer(original, updated) + listener.assert_called_once_with(updated) + self.assertIs(updated, cached) + + def test_on_add__cached_broadcasting_peer_ignored(self): + listener = self._subscribe(EVENT_ADD_INSTANCE) + cached = _make_instance("peer") + cached.set_broadcasting(mock.Mock()) + self.backend.other_instances[cached.name] = cached + rediscovered = _make_instance("peer") + rediscovered.set_broadcasting(mock.Mock()) + self.backend.on_add(rediscovered) + listener.assert_not_called() + self.assertIs(cached, self.backend.other_instances[cached.name]) + + def test_on_remove__is_self(self): + listener = self._subscribe(EVENT_REMOVE_INSTANCE) + peer = _make_instance("peer") + peer.set_broadcasting(mock.Mock(), is_self=True) + self.backend.other_instances[peer.name] = peer + self.backend.on_remove(peer.name) + listener.assert_not_called() + self.assertTrue(peer.is_broadcasting) + + def test_on_remove__not_found(self): + listener = self._subscribe(EVENT_REMOVE_INSTANCE) + self.backend.on_remove(_make_instance("peer").name) + listener.assert_not_called() + + # --- the cache lookup the transport uses to skip re-querying a peer --- + + def test_is_known_only_for_cached_broadcasting_peer(self): + peer = _make_instance("peer") + self.assertFalse(self.backend.is_known(peer.name)) + self.backend.other_instances[peer.name] = peer + self.assertFalse(self.backend.is_known(peer.name)) + peer.set_broadcasting(mock.Mock()) + self.assertTrue(self.backend.is_known(peer.name)) + + # --- transport resolution + lifecycle tests --- + + def test_start_broadcast_uses_default_transport(self): + transport = self._start_with_transport() + self.assertIn("register", transport.calls) + + def test_override_transport_preferred_over_default(self): + default_transport = _register_transport("DefaultTransport", as_default=True) + override_transport = _register_transport("OverrideTransport") + self.backend.start_broadcast() + self.assertEqual(default_transport.calls, []) + self.assertIn("register", override_transport.calls) + self.assertIn("start_listening", override_transport.calls) + + def test_transport_can_ask_whether_a_peer_is_already_cached(self): + transport = self._start_with_transport() + peer = _make_instance("peer") + peer.set_broadcasting(mock.Mock()) + self.backend.on_add(peer) + self.assertTrue(transport.is_known(peer.name)) + + def test_start_broadcast_publishes_register_after_the_transport_registered(self): + # the transport settles our `zeroconf_id` while registering, and can + # still raise on a name conflict; publishing first shows listeners an + # unsettled id, and one for a registration that never happened + transport = _register_transport(as_default=True) + self.backend.events.subscribe( + EVENT_REGISTER_INSTANCE, lambda instance: transport.calls.append("publish") + ) + self.backend.start_broadcast() + self.assertEqual(["register", "publish", "start_listening"], transport.calls) + + def test_start_broadcast__stopped_while_registering(self): + transport = _register_transport(as_default=True) + transport.on_register = self.backend.stop_broadcast + listener = self._subscribe(EVENT_REGISTER_INSTANCE) + self.backend.start_broadcast() + self.assertEqual(["register", "unregister", "stop_listening"], transport.calls) + listener.assert_not_called() + + def test_update_broadcast_publishes_renew(self): + transport = self._start_with_transport() + # the transport may have renamed us on a name conflict, and the replacement + # instance has to keep broadcasting under the name peers already know + self.instance.zeroconf_id = "abcd-1" + listener = self._subscribe(EVENT_RENEW_INSTANCE) + new_instance = _make_instance("abcd") + self.backend.update_broadcast(instance=new_instance) + self.assertIs(new_instance, self.backend.instance) + self.assertEqual("abcd-1", self.backend.instance.zeroconf_id) + listener.assert_called_once() + self.assertIn("update", transport.calls) + + def test_update_broadcast_rebound_cycles_id_and_unregisters(self): + transport = self._start_with_transport(rebound=True) + old_id = self.backend.id + ids = [] + + def on_unregister(instance): + transport.calls.append("publish") + ids.append(self.backend.id) + + self.backend.events.subscribe(EVENT_UNREGISTER_INSTANCE, on_unregister) + self.backend.update_broadcast() + self.assertNotEqual(self.backend.id, old_id) + # the id cycles, and UNREGISTER publishes, before the transport rebinds: + # a peer the rebind rediscovers is enqueued under `backend.id`, and the + # reset UNREGISTER triggers deletes locations held under any other id + self.assertEqual([self.backend.id], ids) + self.assertEqual( + ["register", "start_listening", "update", "publish", "rebind"], + transport.calls, + ) + + def test_update_broadcast_no_rebound_keeps_id(self): + self._start_with_transport(rebound=False) + old_id = self.backend.id + self.backend.update_broadcast() + self.assertEqual(self.backend.id, old_id) + + def test_stop_broadcast_unregisters_and_clears_cache(self): + transport = self._start_with_transport() + self.backend.other_instances["peer"] = _make_instance("peer") + listener = self._subscribe(EVENT_UNREGISTER_INSTANCE) + self.backend.stop_broadcast() + listener.assert_called_once() + self.assertLess( + transport.calls.index("unregister"), + transport.calls.index("stop_listening"), + ) + self.assertEqual(self.backend.other_instances, {}) + + def test_start_broadcast_without_transport_loses_discovery_only(self): + # a platform whose transport override fails to register must lose + # discovery, not take server startup down with it + listener = self._subscribe(EVENT_REGISTER_INSTANCE) + self.backend.start_broadcast() + listener.assert_not_called() + self.assertIsNone(self.backend.transport) + + def test_start_broadcast__already_broadcasting(self): + transport = self._start_with_transport() + self.backend.start_broadcast() + self.assertEqual(["register", "start_listening"], transport.calls) + + def test_stop_broadcast__never_started(self): + # `start_broadcast` bailing out leaves the backend in the plugin's hands, + # so STOP still reaches here; publishing UNREGISTER would reset connection + # states for a broadcast id that never had any + listener = self._subscribe(EVENT_UNREGISTER_INSTANCE) + self.backend.start_broadcast() + self.backend.stop_broadcast() + listener.assert_not_called() + + def test_stop_broadcast__register_raised(self): + # same, for a transport that resolved but failed to come up — and since + # that leaves nothing for `stop_broadcast` to release, the half-finished + # register has to be torn down where it failed + transport = _register_transport(as_default=True) + transport.on_register = mock.Mock(side_effect=OSError("no interfaces")) + listener = self._subscribe(EVENT_UNREGISTER_INSTANCE) + with self.assertRaises(OSError): + self.backend.start_broadcast() + self.backend.stop_broadcast() + listener.assert_not_called() + self.assertEqual(["register", "stop_listening"], transport.calls) + + def test_update_broadcast__never_started(self): + listener = self._subscribe(EVENT_RENEW_INSTANCE) + self.backend.start_broadcast() + self.backend.update_broadcast(instance=_make_instance("abcd")) + listener.assert_not_called() diff --git a/kolibri/core/discovery/test/test_network_broadcast.py b/kolibri/core/discovery/test/test_network_broadcast.py index e783f1bd6f0..bddc71ef980 100644 --- a/kolibri/core/discovery/test/test_network_broadcast.py +++ b/kolibri/core/discovery/test/test_network_broadcast.py @@ -4,33 +4,16 @@ import pytest from django.test import SimpleTestCase from magicbus import Bus -from zeroconf import NonUniqueNameException from zeroconf import ServiceInfo -from zeroconf import Zeroconf -from ..utils.network.broadcast import BARE_LOCAL_LABEL -from ..utils.network.broadcast import filter_lan_addresses -from ..utils.network.broadcast import get_outgoing_interface_address -from ..utils.network.broadcast import KolibriBroadcast +from ..utils.network.broadcast import INSTANCE_EVENTS from ..utils.network.broadcast import KolibriInstance from ..utils.network.broadcast import KolibriInstanceListener from ..utils.network.broadcast import LOCAL_DOMAIN -from ..utils.network.broadcast import LOCAL_EVENTS -from ..utils.network.broadcast import LOCAL_NAME_BARE -from ..utils.network.broadcast import LOCAL_NAME_DEVICE -from ..utils.network.broadcast import NETWORK_EVENTS -from ..utils.network.broadcast import SERVICE_TTL +from ..utils.network.broadcast import NetworkDiscoveryBackend from ..utils.network.broadcast import SERVICE_TYPE -from ..utils.network.broadcast import slugify_device_name MOCK_INTERFACE_IP = "111.222.111.222" -MOCK_LAN_IP = "192.168.1.5" -# A second RFC1918 address on a different subnet, e.g. a Docker/Hyper-V bridge, -# not reachable from LAN peers. Sorts *before* MOCK_LAN_IP as a string, so a -# naive min() over the LAN-filtered addresses would wrongly pick it. -MOCK_SECONDARY_LAN_IP = "172.27.63.113" -MOCK_CGNAT_IP = "100.64.0.5" # Tailscale-style CGNAT address, not LAN-reachable -MOCK_LINK_LOCAL_IP = "169.254.1.1" MOCK_PORT = 555 MOCK_ID = "abba" MOCK_PROPERTIES = { @@ -41,80 +24,23 @@ b"operating_system": '"OS/2"', } BROADCAST_MODULE = "kolibri.core.discovery.utils.network.broadcast." -ZEROCONF_NEEDS_UPDATE = getattr(Zeroconf, "update_interfaces", None) is None -ALL_EVENTS = NETWORK_EVENTS.union(LOCAL_EVENTS) -class SlugifyDeviceNameTestCase(SimpleTestCase): - def test_lowercases_and_strips_punctuation(self): - self.assertEqual("tonyslaptop", slugify_device_name("Tony's Laptop")) - - def test_keeps_digits_and_hyphens(self): - self.assertEqual("device-42", slugify_device_name("Device-42")) - - def test_all_whitespace_yields_empty(self): - self.assertEqual("", slugify_device_name(" ")) - - def test_non_ascii_yields_empty(self): - self.assertEqual("", slugify_device_name("日本語")) - - def test_mixed_ascii_and_non_ascii_keeps_ascii_remainder(self): - self.assertEqual("caf", slugify_device_name("Café")) - - def test_long_name_truncated_to_max_label_length(self): - self.assertEqual("a" * 32, slugify_device_name("a" * 64)) - - -class FilterLanAddressesTestCase(SimpleTestCase): - def test_keeps_rfc1918_addresses(self): - self.assertEqual([MOCK_LAN_IP], filter_lan_addresses([MOCK_LAN_IP])) - - def test_excludes_cgnat_addresses(self): - self.assertEqual([], filter_lan_addresses([MOCK_CGNAT_IP])) - - def test_excludes_link_local_addresses(self): - self.assertEqual([], filter_lan_addresses([MOCK_LINK_LOCAL_IP])) - - def test_excludes_loopback_addresses(self): - self.assertEqual([], filter_lan_addresses(["127.0.0.1"])) - - def test_excludes_public_addresses(self): - self.assertEqual([], filter_lan_addresses(["8.8.8.8"])) - - def test_mixed_addresses_keeps_only_lan(self): - self.assertEqual( - [MOCK_LAN_IP], - filter_lan_addresses([MOCK_CGNAT_IP, MOCK_LINK_LOCAL_IP, MOCK_LAN_IP]), - ) - - -class GetOutgoingInterfaceAddressTestCase(SimpleTestCase): - @mock.patch(BROADCAST_MODULE + "socket.socket") - def test_returns_routing_table_source_address(self, mock_socket): - sock = mock_socket.return_value - sock.getsockname.return_value = (MOCK_LAN_IP, 9) - self.assertEqual(MOCK_LAN_IP, get_outgoing_interface_address()) - sock.close.assert_called_once_with() - - @mock.patch(BROADCAST_MODULE + "socket.socket") - def test_returns_none_when_no_default_route(self, mock_socket): - sock = mock_socket.return_value - sock.connect.side_effect = OSError() - self.assertIsNone(get_outgoing_interface_address()) - sock.close.assert_called_once_with() +def build_service_info(zeroconf_id="test", properties=None): + """A `ServiceInfo` as Zeroconf would hand one back for `zeroconf_id`.""" + return ServiceInfo( + SERVICE_TYPE, + "{}.{}".format(zeroconf_id, SERVICE_TYPE), + address=socket.inet_aton(MOCK_INTERFACE_IP), + port=MOCK_PORT, + server="{}.{}.".format(zeroconf_id, LOCAL_DOMAIN), + properties=properties or MOCK_PROPERTIES.copy(), + ) class KolibriInstanceTestCase(SimpleTestCase): def _build_info(self, properties=None): - properties = properties or MOCK_PROPERTIES.copy() - return ServiceInfo( - SERVICE_TYPE, - "test.{}".format(SERVICE_TYPE), - address=socket.inet_aton(MOCK_INTERFACE_IP), - port=MOCK_PORT, - server="test.{}.".format(LOCAL_DOMAIN), - properties=properties, - ) + return build_service_info(properties=properties) def test_name(self): instance = KolibriInstance("abc") @@ -265,17 +191,16 @@ def update_instance(self, instance): def remove_instance(self, instance): self.mock.remove_instance(instance) + # the local-name channel lives on the transport's bus; the transport tests + # subscribe this same listener to it def update_local_names(self, hostnames): self.mock.update_local_names(hostnames) -@pytest.mark.parametrize( - "event_name", - [event for event in ALL_EVENTS if hasattr(KolibriTestInstanceListener, event)], -) +@pytest.mark.parametrize("event_name", INSTANCE_EVENTS) def test_instance_listener_events(event_name): - events = Bus(extra_channels=ALL_EVENTS) - broadcast = mock.Mock(spec_set=KolibriBroadcast)(KolibriInstance(MOCK_ID)) + events = Bus(extra_channels=INSTANCE_EVENTS) + broadcast = mock.Mock(spec_set=NetworkDiscoveryBackend)(KolibriInstance(MOCK_ID)) broadcast.events = events listener = KolibriTestInstanceListener(broadcast) listener.subscribe() @@ -283,825 +208,3 @@ def test_instance_listener_events(event_name): new_instance = KolibriInstance("abc") events.publish(event_name, new_instance) getattr(listener.mock, event_name).assert_called_once_with(new_instance) - - -class KolibriInstanceListenerTestCase(SimpleTestCase): - def setUp(self): - super().setUp() - self.instance = KolibriInstance(MOCK_ID) - self.broadcast = mock.Mock(spec_set=KolibriBroadcast)(self.instance) - self.events = Bus(extra_channels=ALL_EVENTS) - self.broadcast.events = self.events - self.listener = KolibriTestInstanceListener(self.broadcast) - - def assertHasListener(self, event): - self.assertGreaterEqual(len(self.events.listeners.get(event)), 0) - - def assertHasNoListener(self, event): - self.assertEqual(len(self.events.listeners.get(event)), 0) - - -class KolibriBroadcastTestCase(SimpleTestCase): - def setUp(self): - super().setUp() - self.instance = mock.Mock(spec_set=KolibriInstance)( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - self.instance.ip = MOCK_INTERFACE_IP - self.instance.port = MOCK_PORT - self.instance.device_info = {} - self.zeroconf = mock.MagicMock(spec_set=Zeroconf)() - self.broadcast = KolibriBroadcast(self.instance) - self.listener = self.broadcast.add_listener(KolibriTestInstanceListener) - get_all_addresses_patcher = mock.patch( - BROADCAST_MODULE + "get_all_addresses", return_value=[MOCK_LAN_IP] - ) - get_all_addresses_patcher.start() - self.addCleanup(get_all_addresses_patcher.stop) - # Default to "no default route" so tests don't open a real socket and - # address selection falls back to the LAN-filtered addresses. Tests - # exercising the outgoing-interface preference override this. - outgoing_patcher = mock.patch( - BROADCAST_MODULE + "get_outgoing_interface_address", return_value=None - ) - self.mock_outgoing_interface_address = outgoing_patcher.start() - self.addCleanup(outgoing_patcher.stop) - - def _register_with_device_name(self, device_name): - self.broadcast.zeroconf = self.zeroconf - self.instance.device_info = {"device_name": device_name} - self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)( - "primary" - ) - self.broadcast.register() - - def test_is_broadcasting(self): - self.assertFalse(self.broadcast.is_broadcasting) - self.broadcast.zeroconf = self.zeroconf - self.assertTrue(self.broadcast.is_broadcasting) - - @pytest.mark.skipif(ZEROCONF_NEEDS_UPDATE, reason="Needs updated Zeroconf") - def test_addresses(self): - self.assertEqual(set(), self.broadcast.addresses) - self.broadcast.zeroconf = self.zeroconf - self.zeroconf.interfaces = [MOCK_INTERFACE_IP] - self.assertEqual({MOCK_INTERFACE_IP}, self.broadcast.addresses) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast.register") - @mock.patch(BROADCAST_MODULE + "Zeroconf") - def test_start_broadcast(self, mock_zeroconf, mock_register): - mock_zeroconf.return_value = self.zeroconf - self.broadcast.start_broadcast() - mock_zeroconf.assert_called_once_with(interfaces=self.broadcast.interfaces) - mock_register.assert_called_once() - - @mock.patch(BROADCAST_MODULE + "logger.error") - def test_start_broadcast__already_broadcasting(self, mock_logger): - self.broadcast.zeroconf = self.zeroconf - self.broadcast.start_broadcast() - mock_logger.assert_called_once() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast.renew") - def test_update_broadcast__instance(self, mock_renew): - self.instance.zeroconf_id = "abc-1" - updated_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - updated_instance.zeroconf_id = "abc" - self.assertNotEqual( - self.broadcast.instance.zeroconf_id, updated_instance.zeroconf_id - ) - self.broadcast.zeroconf = self.zeroconf - self.broadcast.update_broadcast(instance=updated_instance) - self.assertEqual(updated_instance, self.broadcast.instance) - self.assertEqual("abc-1", self.broadcast.instance.zeroconf_id) - mock_renew.assert_called_once() - - @pytest.mark.skipif(ZEROCONF_NEEDS_UPDATE, reason="Needs updated Zeroconf") - def test_update_broadcast__interfaces(self): - new_interfaces = [MOCK_INTERFACE_IP] - self.assertNotEqual(new_interfaces, self.broadcast.interfaces) - self.broadcast.zeroconf = self.zeroconf - self.broadcast.update_broadcast(interfaces=new_interfaces) - self.assertEqual(new_interfaces, self.broadcast.interfaces) - self.zeroconf.update_interfaces.assert_called_once_with( - interfaces=new_interfaces - ) - - @mock.patch(BROADCAST_MODULE + "logger.error") - def test_update_broadcast__not_broadcasting(self, mock_logger): - self.broadcast.update_broadcast() - mock_logger.assert_called_once() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast.unregister") - def test_stop_broadcast(self, mock_unregister): - self.broadcast.zeroconf = self.zeroconf - self.broadcast.stop_broadcast() - mock_unregister.assert_called_once() - self.zeroconf.close.assert_called_once() - self.assertIsNone(self.broadcast.zeroconf) - - @mock.patch(BROADCAST_MODULE + "logger.error") - def test_stop_broadcast__not_broadcasting(self, mock_logger): - self.broadcast.stop_broadcast() - mock_logger.assert_called_once() - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register(self, mock_logger): - self.broadcast.zeroconf = self.zeroconf - service_info = mock.Mock(spec_set=ServiceInfo)("test") - self.instance.to_service_info.return_value = service_info - self.broadcast.register() - mock_logger.assert_called_once() - self.instance.to_service_info.assert_called_once_with(self.instance.zeroconf_id) - self.zeroconf.check_service.assert_any_call(service_info, False) - self.zeroconf.register_service.assert_any_call(service_info, ttl=60) - self.instance.set_broadcasting.assert_called_once_with( - service_info, is_self=True - ) - self.listener.mock.register_instance.assert_called_once_with(self.instance) - self.listener.mock.update_local_names.assert_called_once_with(["kolibri.local"]) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__rename(self, mock_logger): - self.broadcast.zeroconf = self.zeroconf - self.instance.id = "test" - self.instance.zeroconf_id = "test" - service_info_not_unique = mock.Mock(spec_set=ServiceInfo)("test") - service_info_unique = mock.Mock(spec_set=ServiceInfo)("test-1") - self.instance.to_service_info.side_effect = [ - service_info_not_unique, - service_info_unique, - ] - self.zeroconf.check_service.side_effect = [ - NonUniqueNameException(), - None, - None, - ] - self.broadcast.register() - mock_logger.assert_called_once() - self.instance.to_service_info.assert_any_call(self.instance.zeroconf_id) - self.instance.to_service_info.assert_called_with( - self.instance.zeroconf_id + "-1" - ) - self.zeroconf.check_service.assert_any_call(service_info_not_unique, False) - self.zeroconf.check_service.assert_any_call(service_info_unique, False) - self.zeroconf.register_service.assert_any_call(service_info_unique, ttl=60) - self.instance.set_broadcasting.assert_called_once_with( - service_info_unique, is_self=True - ) - - @mock.patch(BROADCAST_MODULE + "SERVICE_RENAME_ATTEMPTS", 0) - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__rename_fail(self, mock_logger, *args): - self.broadcast.zeroconf = self.zeroconf - self.instance.id = "test" - self.instance.zeroconf_id = "test" - service_info_not_unique = mock.Mock(spec_set=ServiceInfo)("test") - self.instance.to_service_info.return_value = service_info_not_unique - self.zeroconf.check_service.side_effect = [ - NonUniqueNameException(), - ] - with self.assertRaises(NonUniqueNameException): - self.broadcast.register() - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__not_broadcasting(self, mock_logger): - self.broadcast.register() - mock_logger.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__local_names(self, mock_logger): - self.broadcast.zeroconf = self.zeroconf - service_info = mock.Mock(spec_set=ServiceInfo)("test") - self.instance.to_service_info.return_value = service_info - self.broadcast.register() - bare_label, bare_service = self.broadcast.local_names[LOCAL_NAME_BARE] - self.assertEqual(BARE_LOCAL_LABEL, bare_label) - self.assertEqual("kolibri.local.", bare_service.server) - self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address) - self.assertNotIn(LOCAL_NAME_DEVICE, self.broadcast.local_names) - - @mock.patch(BROADCAST_MODULE + "get_all_addresses") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__local_names_lan_address_selection( - self, mock_logger, mock_get_all_addresses - ): - self.broadcast.zeroconf = self.zeroconf - service_info = mock.Mock(spec_set=ServiceInfo)("test") - self.instance.to_service_info.return_value = service_info - cases = [ - ( - [MOCK_CGNAT_IP, MOCK_LINK_LOCAL_IP, MOCK_LAN_IP], - socket.inet_aton(MOCK_LAN_IP), - ), - ([MOCK_CGNAT_IP], None), - ] - for addresses, expected_address in cases: - mock_get_all_addresses.return_value = addresses - self.broadcast.register() - _, bare_service = self.broadcast.local_names[LOCAL_NAME_BARE] - self.assertEqual(expected_address, bare_service.address) - - @mock.patch(BROADCAST_MODULE + "get_all_addresses") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__prefers_outgoing_interface_address( - self, mock_logger, mock_get_all_addresses - ): - # On a multi-homed host both RFC1918 addresses survive the LAN filter, - # so we must advertise the default-route interface, not whichever one - # sorts first. Reproduces the QA-reported case where a Docker-bridge - # address was handed to LAN peers that couldn't reach it. - self.broadcast.zeroconf = self.zeroconf - self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)( - "primary" - ) - mock_get_all_addresses.return_value = [MOCK_SECONDARY_LAN_IP, MOCK_LAN_IP] - self.mock_outgoing_interface_address.return_value = MOCK_LAN_IP - self.broadcast.register() - _, bare_service = self.broadcast.local_names[LOCAL_NAME_BARE] - self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address) - - @mock.patch(BROADCAST_MODULE + "get_all_addresses") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__falls_back_when_outgoing_not_lan_reachable( - self, mock_logger, mock_get_all_addresses - ): - # If the outgoing interface isn't LAN-reachable (e.g. a VPN default - # route filtered out as CGNAT), fall back to a LAN-filtered address - # rather than advertising the unreachable one. - self.broadcast.zeroconf = self.zeroconf - self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)( - "primary" - ) - mock_get_all_addresses.return_value = [MOCK_CGNAT_IP, MOCK_LAN_IP] - self.mock_outgoing_interface_address.return_value = MOCK_CGNAT_IP - self.broadcast.register() - _, bare_service = self.broadcast.local_names[LOCAL_NAME_BARE] - self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__device_name_alias(self, mock_logger): - self._register_with_device_name("Tony's Laptop") - device_label, device_service = self.broadcast.local_names[LOCAL_NAME_DEVICE] - self.assertEqual("tonyslaptop", device_label) - self.assertEqual("tonyslaptop.local.", device_service.server) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__device_name_alias_empty_slug(self, mock_logger): - self._register_with_device_name(" ") - self.assertNotIn(LOCAL_NAME_DEVICE, self.broadcast.local_names) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_register__local_name_conflict_skipped(self, mock_logger): - # These aliases make no attempt to stay unique: if the name is already - # claimed on the network, we skip ours rather than renaming or crashing - # the whole broadcast. - self.broadcast.zeroconf = self.zeroconf - self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)( - "primary" - ) - # primary registers fine; the bare alias is already claimed - self.zeroconf.register_service.side_effect = [None, NonUniqueNameException()] - self.broadcast.register() # must not raise - self.assertNotIn(LOCAL_NAME_BARE, self.broadcast.local_names) - self.assertEqual([], self.broadcast.local_hostnames) - - def test_local_hostnames(self): - self._register_with_device_name("My Device") - self.assertEqual( - {"kolibri.local", "mydevice.local"}, - set(self.broadcast.local_hostnames), - ) - self.assertEqual( - {"kolibri.local", "mydevice.local"}, - set(self.listener.mock.update_local_names.call_args[0][0]), - ) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_renew(self, mock_logger): - self.broadcast.zeroconf = self.zeroconf - service_info = mock.Mock(spec_set=ServiceInfo)("test") - self.instance.to_service_info.return_value = service_info - self.broadcast.renew() - mock_logger.assert_called_once() - self.instance.to_service_info.assert_called_once_with() - self.zeroconf.update_service.assert_called_once_with(service_info, ttl=60) - self.instance.set_broadcasting.assert_called_once_with( - service_info, is_self=True - ) - self.listener.mock.renew_instance.assert_called_once_with(self.instance) - self.listener.mock.update_local_names.assert_called_once_with([]) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_renew__not_broadcasting(self, mock_logger): - self.broadcast.renew() - mock_logger.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "get_all_addresses") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_renew__local_names_follow_lan_address_change( - self, mock_logger, mock_get_all_addresses - ): - mock_get_all_addresses.return_value = [MOCK_LAN_IP] - self._register_with_device_name("Some Name") - - new_lan_ip = "192.168.1.9" - mock_get_all_addresses.return_value = [new_lan_ip] - self.broadcast.renew() - - _, bare_service = self.broadcast.local_names[LOCAL_NAME_BARE] - self.assertEqual(socket.inet_aton(new_lan_ip), bare_service.address) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_renew__device_name_changed(self, mock_logger): - self._register_with_device_name("Old Name") - old_service = self.broadcast.local_names[LOCAL_NAME_DEVICE][1] - - self.instance.device_info = {"device_name": "New Name"} - self.broadcast.renew() - - self.zeroconf.unregister_service.assert_any_call(old_service) - new_label, new_service = self.broadcast.local_names[LOCAL_NAME_DEVICE] - self.assertEqual("newname", new_label) - self.assertEqual("newname.local.", new_service.server) - self.zeroconf.register_service.assert_any_call(new_service, ttl=new_service.ttl) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_renew__device_name_changed_to_empty_slug(self, mock_logger): - self._register_with_device_name("Old Name") - old_service = self.broadcast.local_names[LOCAL_NAME_DEVICE][1] - - self.instance.device_info = {"device_name": " "} - self.broadcast.renew() - - self.zeroconf.unregister_service.assert_any_call(old_service) - self.assertNotIn(LOCAL_NAME_DEVICE, self.broadcast.local_names) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_renew__device_name_unchanged(self, mock_logger): - self._register_with_device_name("Same Name") - self.zeroconf.register_service.reset_mock() - self.zeroconf.unregister_service.reset_mock() - - self.broadcast.renew() - - self.zeroconf.unregister_service.assert_not_called() - self.zeroconf.register_service.assert_not_called() - # 1 for the primary instance (pre-existing, unchanged renew() logic) - # + 2 for the bare and device aliases, both re-announced with fresh port/ttl - self.assertEqual(3, self.zeroconf.update_service.call_count) - - def test_unregister(self): - self.broadcast.zeroconf = self.zeroconf - self.instance.service_info = mock.Mock(spec_set=ServiceInfo)("test") - self.broadcast.unregister() - self.zeroconf.unregister_service.assert_called_once_with( - self.instance.service_info - ) - self.instance.reset_broadcasting.assert_called_once_with() - self.listener.mock.unregister_instance.assert_called_once_with(self.instance) - self.listener.mock.update_local_names.assert_called_once_with([]) - - def test_unregister__not_broadcasting(self): - self.broadcast.unregister() - self.zeroconf.unregister_service.assert_not_called() - - def test_unregister__local_names(self): - self.instance.service_info = mock.Mock(spec_set=ServiceInfo)("test") - self._register_with_device_name("Some Name") - bare_service = self.broadcast.local_names[LOCAL_NAME_BARE][1] - device_service = self.broadcast.local_names[LOCAL_NAME_DEVICE][1] - - self.broadcast.unregister() - - self.zeroconf.unregister_service.assert_any_call(bare_service) - self.zeroconf.unregister_service.assert_any_call(device_service) - self.assertEqual({}, self.broadcast.local_names) - self.listener.mock.update_local_names.assert_called_with([]) - - @mock.patch(__name__ + ".KolibriTestInstanceListener.subscribe") - def test_add_listener(self, mock_subscribe): - self.broadcast.add_listener(KolibriTestInstanceListener) - mock_subscribe.assert_called_once_with() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_add_service(self, mock_logger, mock_get_service_info, mock_build_instance): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - mock_build_instance.return_value = expected_instance - self.broadcast.add_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.add_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_add_service__cached( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance.service_info = True - self.broadcast.other_instances["test"] = expected_instance - self.broadcast.add_service("test") - mock_get_service_info.assert_not_called() - mock_build_instance.assert_not_called() - mock_logger.assert_not_called() - self.listener.mock.add_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_add_service__cached__not_broadcasting( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - existing_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - self.broadcast.other_instances["test"] = existing_instance - mock_build_instance.return_value = expected_instance - self.broadcast.add_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.add_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_add_service__not_found( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - mock_get_service_info.return_value = None - self.broadcast.add_service("test") - self.assertIsNone(self.broadcast.other_instances.get("test")) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_not_called() - mock_logger.assert_not_called() - self.listener.mock.add_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_add_service__is_self( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - instance = KolibriInstance(MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT) - instance.is_self = True - mock_build_instance.return_value = instance - self.broadcast.add_service("test") - self.assertIsNone(self.broadcast.other_instances.get("test")) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_not_called() - self.listener.mock.add_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - mock_build_instance.return_value = expected_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast.remove_service") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__not_found( - self, - mock_logger, - mock_get_service_info, - mock_build_instance, - mock_remove_service, - ): - mock_get_service_info.return_value = None - mock_remove_service.return_value = None - self.broadcast.update_service("test") - self.assertIsNone(self.broadcast.other_instances.get("test")) - mock_get_service_info.assert_called_once_with("test") - mock_remove_service.assert_called_once_with("test") - mock_build_instance.assert_not_called() - mock_logger.assert_not_called() - self.listener.mock.update_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__is_self( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - instance = KolibriInstance(MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT) - instance.is_self = True - mock_build_instance.return_value = instance - self.broadcast.update_service("test") - self.assertIsNone(self.broadcast.other_instances.get("test")) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_not_called() - self.listener.mock.update_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__no_change_less_than_TTL( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_not_called() - self.listener.mock.update_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__no_change_more_than_or_equal_to_TTL( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance.last_seen = original_instance.last_seen + SERVICE_TTL - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__change_id( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance( - "not the same id", ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__change_ip( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance(MOCK_ID, ip="211.211.16.1", port=MOCK_PORT) - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__change_port( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance(MOCK_ID, ip=MOCK_INTERFACE_IP, port="2121") - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__change_host( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT, host="http://test.com" - ) - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT, host="http://test2.com" - ) - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__change_device_info( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance( - MOCK_ID, - ip=MOCK_INTERFACE_IP, - port=MOCK_PORT, - device_info={"kolibri_version": "0.15.12"}, - ) - expected_instance = KolibriInstance( - MOCK_ID, - ip=MOCK_INTERFACE_IP, - port=MOCK_PORT, - device_info={"kolibri_version": "0.16.0"}, - ) - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance") - @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info") - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_update_service__change_prefix( - self, mock_logger, mock_get_service_info, mock_build_instance - ): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - mock_get_service_info.return_value = service_info - original_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance = KolibriInstance( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT, prefix="/kolibri" - ) - mock_build_instance.return_value = expected_instance - self.broadcast.other_instances["test"] = original_instance - self.broadcast.update_service("test") - self.assertEqual(expected_instance, self.broadcast.other_instances["test"]) - mock_get_service_info.assert_called_once_with("test") - mock_build_instance.assert_called_once_with(service_info) - mock_logger.assert_called_once() - self.listener.mock.update_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_remove_service(self, mock_logger): - expected_instance = mock.Mock(spec_set=KolibriInstance)( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance.service_info = True - expected_instance.is_self = False - self.broadcast.other_instances["test"] = expected_instance - self.broadcast.remove_service("test") - mock_logger.assert_called_once() - expected_instance.reset_broadcasting.assert_called_once() - self.listener.mock.remove_instance.assert_called_once_with(expected_instance) - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_remove_service__is_self(self, mock_logger): - expected_instance = mock.Mock(spec_set=KolibriInstance)( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - expected_instance.service_info = True - expected_instance.is_self = True - self.broadcast.other_instances["test"] = expected_instance - self.broadcast.remove_service("test") - mock_logger.assert_not_called() - expected_instance.reset_broadcasting.assert_not_called() - self.listener.mock.remove_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "logger.info") - def test_remove_service__not_found(self, mock_logger): - self.broadcast.remove_service("test") - mock_logger.assert_not_called() - self.listener.mock.remove_instance.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "KolibriInstance.from_service_info") - def test_build_instance(self, mock_from_service_info): - service_info = mock.Mock(spec_set=ServiceInfo)("test") - instance = mock.Mock(spec_set=KolibriInstance)( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - instance.zeroconf_id = "abc" - mock_from_service_info.return_value = instance - actual_instance = self.broadcast._build_instance(service_info) - self.assertEqual(instance, actual_instance) - instance.set_broadcasting.assert_called_once_with(service_info, is_self=False) - - @mock.patch(BROADCAST_MODULE + "KolibriInstance.from_service_info") - def test_build_instance__self(self, mock_from_service_info): - self.instance.zeroconf_id = "abc" - service_info = mock.Mock(spec_set=ServiceInfo)("test") - instance = mock.Mock(spec_set=KolibriInstance)( - MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT - ) - instance.zeroconf_id = self.instance.zeroconf_id - mock_from_service_info.return_value = instance - actual_instance = self.broadcast._build_instance(service_info) - self.assertEqual(instance, actual_instance) - instance.set_broadcasting.assert_called_once_with(service_info, is_self=True) - - @mock.patch(BROADCAST_MODULE + "logger.warning") - def test_get_service_info(self, mock_logger): - self.broadcast.zeroconf = self.zeroconf - service_info = mock.Mock(spec_set=ServiceInfo)("test") - self.zeroconf.get_service_info.return_value = service_info - actual_service_info = self.broadcast._get_service_info("test") - self.assertEqual(service_info, actual_service_info) - self.zeroconf.get_service_info.assert_called_once_with( - SERVICE_TYPE, "test", timeout=10000 - ) - mock_logger.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "logger.warning") - def test_get_service_info__not_broadcasting(self, mock_logger): - actual_service_info = self.broadcast._get_service_info("test") - self.assertIsNone(actual_service_info) - self.zeroconf.get_service_info.assert_not_called() - mock_logger.assert_not_called() - - @mock.patch(BROADCAST_MODULE + "logger.warning") - def test_get_service_info__not_found(self, mock_logger): - self.broadcast.zeroconf = self.zeroconf - self.zeroconf.get_service_info.return_value = None - actual_service_info = self.broadcast._get_service_info("test") - self.assertIsNone(actual_service_info) - self.zeroconf.get_service_info.assert_called_once_with( - SERVICE_TYPE, "test", timeout=10000 - ) - mock_logger.assert_called_once() diff --git a/kolibri/core/discovery/test/test_network_search.py b/kolibri/core/discovery/test/test_network_search.py index b257c9dbe4c..ea050f9a055 100644 --- a/kolibri/core/discovery/test/test_network_search.py +++ b/kolibri/core/discovery/test/test_network_search.py @@ -3,8 +3,8 @@ from kolibri.core.tasks.job import Priority -from ..utils.network.broadcast import KolibriBroadcast from ..utils.network.broadcast import KolibriInstance +from ..utils.network.broadcast import NetworkDiscoveryBackend from ..utils.network.search import NetworkLocationListener MOCK_INTERFACE_IP = "111.222.111.222" @@ -39,7 +39,7 @@ def setUp(self): }, ) - self.broadcast = KolibriBroadcast(instance=self.broadcast_instance) + self.broadcast = NetworkDiscoveryBackend(instance=self.broadcast_instance) self.broadcast.id = "abc123" self.listener = NetworkLocationListener(self.broadcast) diff --git a/kolibri/core/discovery/test/test_zeroconf_transport.py b/kolibri/core/discovery/test/test_zeroconf_transport.py new file mode 100644 index 00000000000..97a2f4020ca --- /dev/null +++ b/kolibri/core/discovery/test/test_zeroconf_transport.py @@ -0,0 +1,594 @@ +import socket + +import mock +import pytest +from django.test import SimpleTestCase +from zeroconf import NonUniqueNameException +from zeroconf import ServiceInfo +from zeroconf import ServiceStateChange +from zeroconf import Zeroconf + +from ..utils.network.broadcast import KolibriInstance +from ..utils.network.broadcast import SERVICE_TTL +from ..utils.network.broadcast import SERVICE_TYPE +from ..utils.network.zeroconf_transport import BARE_LOCAL_LABEL +from ..utils.network.zeroconf_transport import EVENT_UPDATE_LOCAL_NAMES +from ..utils.network.zeroconf_transport import filter_lan_addresses +from ..utils.network.zeroconf_transport import get_outgoing_interface_address +from ..utils.network.zeroconf_transport import LOCAL_NAME_BARE +from ..utils.network.zeroconf_transport import LOCAL_NAME_DEVICE +from ..utils.network.zeroconf_transport import slugify_device_name +from ..utils.network.zeroconf_transport import ZeroconfNetworkDiscovery +from .test_network_broadcast import build_service_info +from .test_network_broadcast import KolibriTestInstanceListener +from .test_network_broadcast import MOCK_ID +from .test_network_broadcast import MOCK_INTERFACE_IP +from .test_network_broadcast import MOCK_PORT + +MOCK_LAN_IP = "192.168.1.5" +# A second RFC1918 address on a different subnet, e.g. a Docker/Hyper-V bridge, +# not reachable from LAN peers. Sorts *before* MOCK_LAN_IP as a string, so a +# naive min() over the LAN-filtered addresses would wrongly pick it. +MOCK_SECONDARY_LAN_IP = "172.27.63.113" +MOCK_CGNAT_IP = "100.64.0.5" # Tailscale-style CGNAT address, not LAN-reachable +MOCK_LINK_LOCAL_IP = "169.254.1.1" +PEER_SERVICE_NAME = "peer.{}".format(SERVICE_TYPE) +ZEROCONF_MODULE = "kolibri.core.discovery.utils.network.zeroconf_transport." +ZEROCONF_NEEDS_UPDATE = getattr(Zeroconf, "update_interfaces", None) is None + + +class SlugifyDeviceNameTestCase(SimpleTestCase): + def test_lowercases_and_strips_punctuation(self): + self.assertEqual("tonyslaptop", slugify_device_name("Tony's Laptop")) + + def test_keeps_digits_and_hyphens(self): + self.assertEqual("device-42", slugify_device_name("Device-42")) + + def test_all_whitespace_yields_empty(self): + self.assertEqual("", slugify_device_name(" ")) + + def test_non_ascii_yields_empty(self): + self.assertEqual("", slugify_device_name("日本語")) + + def test_mixed_ascii_and_non_ascii_keeps_ascii_remainder(self): + self.assertEqual("caf", slugify_device_name("Café")) + + def test_long_name_truncated_to_max_label_length(self): + self.assertEqual("a" * 32, slugify_device_name("a" * 64)) + + +class FilterLanAddressesTestCase(SimpleTestCase): + def test_keeps_rfc1918_addresses(self): + self.assertEqual([MOCK_LAN_IP], filter_lan_addresses([MOCK_LAN_IP])) + + def test_excludes_cgnat_addresses(self): + self.assertEqual([], filter_lan_addresses([MOCK_CGNAT_IP])) + + def test_excludes_link_local_addresses(self): + self.assertEqual([], filter_lan_addresses([MOCK_LINK_LOCAL_IP])) + + def test_excludes_loopback_addresses(self): + self.assertEqual([], filter_lan_addresses(["127.0.0.1"])) + + def test_excludes_public_addresses(self): + self.assertEqual([], filter_lan_addresses(["8.8.8.8"])) + + def test_mixed_addresses_keeps_only_lan(self): + self.assertEqual( + [MOCK_LAN_IP], + filter_lan_addresses([MOCK_CGNAT_IP, MOCK_LINK_LOCAL_IP, MOCK_LAN_IP]), + ) + + +class GetOutgoingInterfaceAddressTestCase(SimpleTestCase): + @mock.patch(ZEROCONF_MODULE + "socket.socket") + def test_returns_routing_table_source_address(self, mock_socket): + sock = mock_socket.return_value + sock.getsockname.return_value = (MOCK_LAN_IP, 9) + self.assertEqual(MOCK_LAN_IP, get_outgoing_interface_address()) + sock.close.assert_called_once_with() + + @mock.patch(ZEROCONF_MODULE + "socket.socket") + def test_returns_none_when_no_default_route(self, mock_socket): + sock = mock_socket.return_value + sock.connect.side_effect = OSError() + self.assertIsNone(get_outgoing_interface_address()) + sock.close.assert_called_once_with() + + +LOCAL_HOSTNAMES_MODULE = "kolibri.core.discovery.utils.network.local_hostnames." + + +class ZeroconfNetworkDiscoveryTestCase(SimpleTestCase): + def setUp(self): + super().setUp() + self.instance = mock.Mock(spec_set=KolibriInstance)( + MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT + ) + self.instance.ip = MOCK_INTERFACE_IP + self.instance.port = MOCK_PORT + self.instance.device_info = {} + self.instance.zeroconf_id = MOCK_ID + self.zeroconf = mock.MagicMock(spec_set=Zeroconf)() + # The transport attaches LocalHostnameListener on its first + # register/start_listening, which persists to the DB on local-name + # events; stub the task enqueue so these unit tests stay DB-free. + enqueue_patcher = mock.patch( + LOCAL_HOSTNAMES_MODULE + "sync_local_hostnames.enqueue" + ) + self.mock_enqueue = enqueue_patcher.start() + self.addCleanup(enqueue_patcher.stop) + self.transport = ZeroconfNetworkDiscovery() + # Captures the transport's local-name events (published on its own bus). + self.listener = self.transport.add_listener(KolibriTestInstanceListener) + self.on_add = mock.Mock() + self.on_update = mock.Mock() + self.on_remove = mock.Mock() + self.is_known = mock.Mock(return_value=False) + browser_patcher = mock.patch(ZEROCONF_MODULE + "ServiceBrowser") + self.mock_browser = browser_patcher.start() + self.addCleanup(browser_patcher.stop) + get_all_addresses_patcher = mock.patch( + ZEROCONF_MODULE + "get_all_addresses", return_value=[MOCK_LAN_IP] + ) + get_all_addresses_patcher.start() + self.addCleanup(get_all_addresses_patcher.stop) + # Default to "no default route" so tests don't open a real socket and + # address selection falls back to the LAN-filtered addresses. Tests + # exercising the outgoing-interface preference override this. + outgoing_patcher = mock.patch( + ZEROCONF_MODULE + "get_outgoing_interface_address", return_value=None + ) + self.mock_outgoing_interface_address = outgoing_patcher.start() + self.addCleanup(outgoing_patcher.stop) + + def _prepare_register(self, device_name=None): + """Puts the transport in the state `register` is called in.""" + self.transport.zeroconf = self.zeroconf + if device_name is not None: + self.instance.device_info = {"device_name": device_name} + self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)( + "primary" + ) + + def _register_with_device_name(self, device_name): + self._prepare_register(device_name) + self.transport.register(self.instance) + + @mock.patch(ZEROCONF_MODULE + "Zeroconf") + def test_register_opens_zeroconf_and_registers(self, mock_zeroconf): + mock_zeroconf.return_value = self.zeroconf + service_info = mock.Mock(spec_set=ServiceInfo)("test") + self.instance.to_service_info.return_value = service_info + self.transport.register(self.instance) + mock_zeroconf.assert_called_once_with(interfaces=self.transport.interfaces) + self.assertIs(self.instance, self.transport.instance) + self.zeroconf.check_service.assert_any_call(service_info, False) + self.zeroconf.register_service.assert_any_call(service_info, ttl=SERVICE_TTL) + self.instance.set_broadcasting.assert_called_once_with( + service_info, is_self=True + ) + + @mock.patch(ZEROCONF_MODULE + "Zeroconf") + def test_register_reuses_open_zeroconf(self, mock_zeroconf): + self._prepare_register() + self.transport.register(self.instance) + mock_zeroconf.assert_not_called() + + def test_register_rename(self): + self.transport.zeroconf = self.zeroconf + self.instance.id = "test" + self.instance.zeroconf_id = "test" + service_info_not_unique = mock.Mock(spec_set=ServiceInfo)("test") + service_info_unique = mock.Mock(spec_set=ServiceInfo)("test-1") + self.instance.to_service_info.side_effect = [ + service_info_not_unique, + service_info_unique, + ] + self.zeroconf.check_service.side_effect = [ + NonUniqueNameException(), + None, + ] + self.transport.register(self.instance) + self.instance.to_service_info.assert_called_with(self.instance.id + "-1") + self.zeroconf.register_service.assert_any_call( + service_info_unique, ttl=SERVICE_TTL + ) + + @mock.patch(ZEROCONF_MODULE + "SERVICE_RENAME_ATTEMPTS", 0) + def test_register_rename_gives_up(self): + self.transport.zeroconf = self.zeroconf + self.instance.id = "test" + self.instance.zeroconf_id = "test" + self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)( + "test" + ) + self.zeroconf.check_service.side_effect = [NonUniqueNameException()] + with self.assertRaises(NonUniqueNameException): + self.transport.register(self.instance) + self.zeroconf.register_service.assert_not_called() + + def test_register__local_names(self): + self._prepare_register() + self.transport.register(self.instance) + bare_label, bare_service = self.transport.local_names[LOCAL_NAME_BARE] + self.assertEqual(BARE_LOCAL_LABEL, bare_label) + self.assertEqual("kolibri.local.", bare_service.server) + self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address) + self.assertNotIn(LOCAL_NAME_DEVICE, self.transport.local_names) + + def test_register__local_names_queued_for_persistence(self): + # Nothing listens for local-name events until the transport registers, + # so merely instantiating it — which the plugin registry does in every + # process — doesn't reach the task queue or its models. + self.transport.events.publish(EVENT_UPDATE_LOCAL_NAMES, ["kolibri.local"]) + self.mock_enqueue.assert_not_called() + self._prepare_register() + self.transport.register(self.instance) + # Enqueued rather than written inline: `register` runs on the caller's + # thread, and the hostnames have to be readable from other processes. + self.mock_enqueue.assert_called_once_with( + args=(["kolibri.local"],), + ) + + @mock.patch(ZEROCONF_MODULE + "get_all_addresses") + def test_register__local_names_lan_address_selection(self, mock_get_all_addresses): + self._prepare_register() + cases = [ + ( + [MOCK_CGNAT_IP, MOCK_LINK_LOCAL_IP, MOCK_LAN_IP], + socket.inet_aton(MOCK_LAN_IP), + ), + ([MOCK_CGNAT_IP], None), + ] + for addresses, expected_address in cases: + mock_get_all_addresses.return_value = addresses + self.transport.register(self.instance) + _, bare_service = self.transport.local_names[LOCAL_NAME_BARE] + self.assertEqual(expected_address, bare_service.address) + + @mock.patch(ZEROCONF_MODULE + "get_all_addresses") + def test_register__prefers_outgoing_interface_address(self, mock_get_all_addresses): + # On a multi-homed host both RFC1918 addresses survive the LAN filter, + # so we must advertise the default-route interface, not whichever one + # sorts first. Reproduces the QA-reported case where a Docker-bridge + # address was handed to LAN peers that couldn't reach it. + self._prepare_register() + mock_get_all_addresses.return_value = [MOCK_SECONDARY_LAN_IP, MOCK_LAN_IP] + self.mock_outgoing_interface_address.return_value = MOCK_LAN_IP + self.transport.register(self.instance) + _, bare_service = self.transport.local_names[LOCAL_NAME_BARE] + self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address) + + @mock.patch(ZEROCONF_MODULE + "get_all_addresses") + def test_register__falls_back_when_outgoing_not_lan_reachable( + self, mock_get_all_addresses + ): + # If the outgoing interface isn't LAN-reachable (e.g. a VPN default + # route filtered out as CGNAT), fall back to a LAN-filtered address + # rather than advertising the unreachable one. + self._prepare_register() + mock_get_all_addresses.return_value = [MOCK_CGNAT_IP, MOCK_LAN_IP] + self.mock_outgoing_interface_address.return_value = MOCK_CGNAT_IP + self.transport.register(self.instance) + _, bare_service = self.transport.local_names[LOCAL_NAME_BARE] + self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address) + + def test_register__device_name_alias(self): + self._register_with_device_name("Tony's Laptop") + device_label, device_service = self.transport.local_names[LOCAL_NAME_DEVICE] + self.assertEqual("tonyslaptop", device_label) + self.assertEqual("tonyslaptop.local.", device_service.server) + + def test_register__device_name_alias_empty_slug(self): + self._register_with_device_name(" ") + self.assertNotIn(LOCAL_NAME_DEVICE, self.transport.local_names) + + def test_register__local_name_conflict_skipped(self): + # These aliases make no attempt to stay unique: if the name is already + # claimed on the network, we skip ours rather than renaming or crashing + # the whole broadcast. + self._prepare_register() + # primary registers fine; the bare alias is already claimed + self.zeroconf.register_service.side_effect = [None, NonUniqueNameException()] + self.transport.register(self.instance) # must not raise + self.assertNotIn(LOCAL_NAME_BARE, self.transport.local_names) + self.assertEqual([], self.transport.local_hostnames) + + def test_local_hostnames(self): + self._register_with_device_name("My Device") + self.assertEqual( + {"kolibri.local", "mydevice.local"}, + set(self.transport.local_hostnames), + ) + self.assertEqual( + {"kolibri.local", "mydevice.local"}, + set(self.listener.mock.update_local_names.call_args[0][0]), + ) + + @mock.patch(ZEROCONF_MODULE + "get_all_addresses") + def test_renew__local_names_follow_lan_address_change(self, mock_get_all_addresses): + mock_get_all_addresses.return_value = [MOCK_LAN_IP] + self._register_with_device_name("Some Name") + + new_lan_ip = "192.168.1.9" + mock_get_all_addresses.return_value = [new_lan_ip] + self.transport.renew() + + _, bare_service = self.transport.local_names[LOCAL_NAME_BARE] + self.assertEqual(socket.inet_aton(new_lan_ip), bare_service.address) + + def test_renew__device_name_changed(self): + self._register_with_device_name("Old Name") + old_service = self.transport.local_names[LOCAL_NAME_DEVICE][1] + + self.instance.device_info = {"device_name": "New Name"} + self.transport.renew() + + self.zeroconf.unregister_service.assert_any_call(old_service) + new_label, new_service = self.transport.local_names[LOCAL_NAME_DEVICE] + self.assertEqual("newname", new_label) + self.assertEqual("newname.local.", new_service.server) + self.zeroconf.register_service.assert_any_call(new_service, ttl=new_service.ttl) + + def test_renew__device_name_changed_to_empty_slug(self): + self._register_with_device_name("Old Name") + old_service = self.transport.local_names[LOCAL_NAME_DEVICE][1] + + self.instance.device_info = {"device_name": " "} + self.transport.renew() + + self.zeroconf.unregister_service.assert_any_call(old_service) + self.assertNotIn(LOCAL_NAME_DEVICE, self.transport.local_names) + + def test_renew__device_name_unchanged(self): + self._register_with_device_name("Same Name") + self.zeroconf.register_service.reset_mock() + self.zeroconf.unregister_service.reset_mock() + + self.transport.renew() + + self.zeroconf.unregister_service.assert_not_called() + self.zeroconf.register_service.assert_not_called() + # 1 for the primary instance (pre-existing, unchanged renew() logic) + # + 2 for the bare and device aliases, both re-announced with fresh port/ttl + self.assertEqual(3, self.zeroconf.update_service.call_count) + + def test_unregister(self): + self.instance.service_info = mock.Mock(spec_set=ServiceInfo)("test") + self._register_with_device_name("Some Name") + bare_service = self.transport.local_names[LOCAL_NAME_BARE][1] + device_service = self.transport.local_names[LOCAL_NAME_DEVICE][1] + + self.transport.unregister() + + self.zeroconf.unregister_service.assert_any_call(self.instance.service_info) + self.instance.reset_broadcasting.assert_called_once_with() + self.zeroconf.unregister_service.assert_any_call(bare_service) + self.zeroconf.unregister_service.assert_any_call(device_service) + self.assertEqual({}, self.transport.local_names) + self.listener.mock.update_local_names.assert_called_with([]) + + def test_renew__not_broadcasting(self): + self.transport.instance = self.instance + self.transport.renew() + self.instance.to_service_info.assert_not_called() + self.listener.mock.update_local_names.assert_not_called() + + def test_unregister__not_broadcasting(self): + self.transport.instance = self.instance + self.transport.unregister() + self.instance.reset_broadcasting.assert_not_called() + self.listener.mock.update_local_names.assert_not_called() + + @pytest.mark.skipif(ZEROCONF_NEEDS_UPDATE, reason="Needs updated Zeroconf") + def test_update_does_not_rebind_when_addresses_unchanged(self): + self._register_with_device_name("Some Name") + self.zeroconf.interfaces = [MOCK_LAN_IP] + self.zeroconf.update_service.reset_mock() + on_rebind = mock.Mock() + self.transport.update(self.instance, on_rebind) + on_rebind.assert_not_called() + # no rebind follows, so the renewal has to go out on its own + self.zeroconf.update_service.assert_called() + self.zeroconf.update_interfaces.assert_not_called() + + @pytest.mark.skipif(ZEROCONF_NEEDS_UPDATE, reason="Needs updated Zeroconf") + def test_update_reports_the_rebind_before_rebinding(self): + self._register_with_device_name("Some Name") + # currently bound to a different address than the current one + self.zeroconf.interfaces = [MOCK_INTERFACE_IP] + self.zeroconf.update_service.reset_mock() + # the backend cycles its broadcast id in `on_rebind`, and peers the + # rebind rediscovers have to land under the incoming id + on_rebind = mock.Mock( + side_effect=lambda: self.zeroconf.update_interfaces.assert_not_called() + ) + self.transport.update(self.instance, on_rebind) + on_rebind.assert_called_once_with() + # the rebind broadcasts the renewed services, so the renewal doesn't + self.zeroconf.update_service.assert_not_called() + self.zeroconf.update_interfaces.assert_called_once_with( + interfaces=self.transport.interfaces + ) + + def test_local_name_event_listener_error_propagates(self): + # A raising local-name listener must surface its own exception, not an + # AttributeError from a bus with no `throws` configured. + def boom(hostnames): + raise ValueError("boom") + + self.transport.events.subscribe(EVENT_UPDATE_LOCAL_NAMES, boom) + with self.assertRaises(ValueError): + self.transport.events.publish(EVENT_UPDATE_LOCAL_NAMES, []) + + def test_start_listening_attaches_browser(self): + self.transport.zeroconf = self.zeroconf + self.zeroconf.browsers = {} + self.transport.start_listening( + self.on_add, self.on_update, self.on_remove, self.is_known + ) + # the browser has to dispatch to our handler on the Zeroconf we're + # holding, as that pairing is what `_is_current` checks stale events against + self.mock_browser.assert_called_once_with( + self.zeroconf, + SERVICE_TYPE, + handlers=[self.transport._handle_service_change], + ) + self.assertEqual(self.mock_browser.return_value, self.zeroconf.browsers["bus"]) + + def test_stop_listening_closes_zeroconf(self): + self.transport.zeroconf = self.zeroconf + self.zeroconf.browsers = {} + self.transport.start_listening( + self.on_add, self.on_update, self.on_remove, self.is_known + ) + self.transport.stop_listening() + # closing takes the browser with it, and leaves no Zeroconf for a later + # event to match on + self.zeroconf.close.assert_called_once_with() + self.assertIsNone(self.transport.zeroconf) + + def test_stop_listening_drops_local_names(self): + # the closed Zeroconf no longer advertises the aliases, so this teardown + # path can't leave them behind for the next session either + self._register_with_device_name("Some Name") + self.transport.stop_listening() + self.assertEqual({}, self.transport.local_names) + + def _listen(self): + """Puts the transport in the listening state a browser event arrives in.""" + self.transport.zeroconf = self.zeroconf + self.transport.instance = self.instance + self.instance.is_broadcasting = False + self.transport.start_listening( + self.on_add, self.on_update, self.on_remove, self.is_known + ) + + def _handle(self, name, state_change): + self.transport._handle_service_change( + self.zeroconf, SERVICE_TYPE, name, state_change + ) + + def test_added_event_builds_instance_and_invokes_on_add(self): + self._listen() + self.zeroconf.get_service_info.return_value = build_service_info("peer") + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Added) + (instance,), _ = self.on_add.call_args + self.assertEqual(PEER_SERVICE_NAME, instance.name) + self.assertEqual(MOCK_INTERFACE_IP, instance.ip) + self.assertEqual(MOCK_PORT, instance.port) + self.assertFalse(instance.is_self) + + def test_added_event_missing_service_dispatches_nothing(self): + self._listen() + self.zeroconf.get_service_info.return_value = None + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Added) + self.on_add.assert_not_called() + self.on_remove.assert_not_called() + + def test_added_event_for_our_own_zeroconf_id_marks_is_self(self): + # our own service, seen before `register` finished, so the name + # fast-path can't catch it — `is_self` must, or we'd discover ourselves + self._listen() + self.zeroconf.get_service_info.return_value = build_service_info(MOCK_ID) + self._handle("{}.{}".format(MOCK_ID, SERVICE_TYPE), ServiceStateChange.Added) + (instance,), _ = self.on_add.call_args + self.assertTrue(instance.is_self) + + def test_added_event_for_known_service_skips_query(self): + # querying a service the backend already has cached costs a 10s timeout + # for a result `on_add` would discard + self._listen() + self.is_known.return_value = True + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Added) + self.is_known.assert_called_once_with(PEER_SERVICE_NAME) + self.zeroconf.get_service_info.assert_not_called() + self.on_add.assert_not_called() + + def test_added_event_for_own_service_ignored(self): + self._listen() + self.instance.is_broadcasting = True + self.instance.service_info.name = PEER_SERVICE_NAME + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Added) + self.zeroconf.get_service_info.assert_not_called() + self.on_add.assert_not_called() + + def test_removed_event_invokes_on_remove_without_query(self): + self._listen() + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Removed) + self.on_remove.assert_called_once_with(PEER_SERVICE_NAME) + self.zeroconf.get_service_info.assert_not_called() + + def test_updated_event_missing_service_invokes_on_remove(self): + self._listen() + self.zeroconf.get_service_info.return_value = None + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Updated) + self.on_remove.assert_called_once_with(PEER_SERVICE_NAME) + self.on_update.assert_not_called() + + def test_event_queued_before_stop_listening_is_dropped(self): + self._listen() + self.transport.stop_listening() + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Added) + self.is_known.assert_not_called() + self.zeroconf.get_service_info.assert_not_called() + self.on_add.assert_not_called() + + def test_event_from_a_previous_listening_session_is_dropped(self): + self._listen() + self.transport.stop_listening() + next_on_remove = mock.Mock() + self.transport.zeroconf = mock.MagicMock(spec_set=Zeroconf)() + self.transport.start_listening( + mock.Mock(), mock.Mock(), next_on_remove, mock.Mock(return_value=False) + ) + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Removed) + next_on_remove.assert_not_called() + + def test_event_dispatched_across_a_stop_is_dropped_not_raised(self): + self._listen() + stale_zeroconf = self.transport.zeroconf + self.transport.stop_listening() + + self.transport._added(stale_zeroconf, PEER_SERVICE_NAME) + self.transport._updated(stale_zeroconf, PEER_SERVICE_NAME) + self.transport._on_remove(PEER_SERVICE_NAME) + + self.on_add.assert_not_called() + self.on_update.assert_not_called() + self.on_remove.assert_not_called() + + def test_instance_built_after_a_stop_is_not_our_own(self): + # the stop can also land after `_is_current` passed and before the peer + # instance is built, with our own instance nulled by then + self._listen() + self.transport.stop_listening() + + instance = self.transport._build_instance(build_service_info("peer")) + + self.assertFalse(instance.is_self) + + def test_added_event_stopped_during_query_dispatches_nothing(self): + # the query blocks for up to 10s, ample time for a stop to land + self._listen() + + def stop_then_answer(*args, **kwargs): + self.transport.stop_listening() + return build_service_info("peer") + + self.zeroconf.get_service_info.side_effect = stop_then_answer + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Added) + self.on_add.assert_not_called() + + def test_updated_event_stopped_during_query_dispatches_nothing(self): + self._listen() + + def stop_then_answer(*args, **kwargs): + self.transport.stop_listening() + return None + + self.zeroconf.get_service_info.side_effect = stop_then_answer + self._handle(PEER_SERVICE_NAME, ServiceStateChange.Updated) + self.on_update.assert_not_called() + self.on_remove.assert_not_called() diff --git a/kolibri/core/discovery/utils/network/broadcast.py b/kolibri/core/discovery/utils/network/broadcast.py index b45b62d90e7..052d2d8c419 100644 --- a/kolibri/core/discovery/utils/network/broadcast.py +++ b/kolibri/core/discovery/utils/network/broadcast.py @@ -1,24 +1,17 @@ import json import logging -import re import socket import time import uuid -from collections import namedtuple -from ipaddress import ip_address from magicbus.base import Bus from magicbus.plugins import SimplePlugin from zeroconf import get_all_addresses -from zeroconf import InterfaceChoice -from zeroconf import NonUniqueNameException -from zeroconf import ServiceBrowser from zeroconf import ServiceInfo -from zeroconf import ServiceStateChange from zeroconf import USE_IP_OF_OUTGOING_INTERFACE -from zeroconf import Zeroconf from kolibri.core.device.utils import get_device_info +from kolibri.core.discovery.hooks import NetworkDiscoveryHook from kolibri.utils.conf import OPTIONS SERVICE_TYPE = "Kolibri._sub._http._tcp.local." @@ -26,25 +19,8 @@ TRUE = "TRUE" FALSE = "FALSE" DEFAULT_PORT = 8080 -SERVICE_RENAME_ATTEMPTS = 100 SERVICE_TTL = 60 -LOCAL_TLD = "local" -# Registered under a private subtype (not SERVICE_TYPE), so these aliases -# stay invisible to Kolibri's own peer-discovery ServiceBrowser. -# Two distinct subtypes, so a device name that slugifies to "kolibri" gets -# its own `self.services` entry rather than overwriting the bare alias. -LOCAL_ALIAS_TYPE_BARE = "KolibriLocalBare._sub._http._tcp.local." -LOCAL_ALIAS_TYPE_DEVICE = "KolibriLocalDevice._sub._http._tcp.local." -BARE_LOCAL_LABEL = "kolibri" -LOCAL_NAME_BARE = "bare" -LOCAL_NAME_DEVICE = "device_name" - -# The label the alias advertises (e.g. "kolibri" for the bare name, or the -# slugified device name) and the ServiceInfo registered for it. The label is -# kept so a device rename can be detected without re-parsing the hostname. -LocalName = namedtuple("LocalName", ["label", "service"]) - EVENT_REGISTER_INSTANCE = ( "register_instance" # our local instance is registered on the network ) @@ -52,9 +28,6 @@ EVENT_UNREGISTER_INSTANCE = ( "unregister_instance" # our local instance is unregistered from network ) -EVENT_UPDATE_LOCAL_NAMES = ( - "update_local_names" # the `.local` hostnames we own have changed -) EVENT_ADD_INSTANCE = "add_instance" # a network instance is registered on the network EVENT_UPDATE_INSTANCE = ( "update_instance" # a network instance is updated on the network @@ -62,86 +35,20 @@ EVENT_REMOVE_INSTANCE = ( "remove_instance" # a network instance is removed from the network ) -EVENT_ADD_SERVICE = "add_service" # a Zeroconf service is registered on the network -EVENT_UPDATE_SERVICE = "update_service" # a Zeroconf service is updated on the network -EVENT_REMOVE_SERVICE = ( - "remove_service" # a Zeroconf service is removed from the network -) -LOCAL_EVENTS = { +# the transport-agnostic channels the backend bus carries, each receiving a +# `KolibriInstance` +INSTANCE_EVENTS = [ EVENT_REGISTER_INSTANCE, EVENT_RENEW_INSTANCE, EVENT_UNREGISTER_INSTANCE, - EVENT_UPDATE_LOCAL_NAMES, -} -NETWORK_EVENTS = { - EVENT_ADD_SERVICE, - EVENT_UPDATE_SERVICE, - EVENT_REMOVE_SERVICE, EVENT_ADD_INSTANCE, EVENT_UPDATE_INSTANCE, EVENT_REMOVE_INSTANCE, -} +] logger = logging.getLogger(__name__) -# zeroconf-py2compat's service_type_name() rejects a claimed name whose -# "