Skip to content

Commit de616e4

Browse files
rtibblesbotclaude
andcommitted
Restore transport coverage of .local name aliasing
The KolibriBroadcast test-class deletion dropped ~13 tests covering the .local hostname aliasing (bare/device-name aliases, multi-homed LAN address selection, outgoing-interface preference, conflict-skipping, rename and unregister handling). That behaviour was copied verbatim into ZeroconfNetworkDiscovery, not changed, so port the tests onto the transport test case rather than leaving the transport's local-name methods untested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b212ced commit de616e4

1 file changed

Lines changed: 192 additions & 1 deletion

File tree

kolibri/core/discovery/test/test_network_broadcast.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from zeroconf import ServiceStateChange
1010
from zeroconf import Zeroconf
1111

12+
from ..utils.network.broadcast import BARE_LOCAL_LABEL
1213
from ..utils.network.broadcast import EVENT_ADD_INSTANCE
1314
from ..utils.network.broadcast import EVENT_REGISTER_INSTANCE
1415
from ..utils.network.broadcast import EVENT_REMOVE_INSTANCE
@@ -21,6 +22,8 @@
2122
from ..utils.network.broadcast import KolibriInstance
2223
from ..utils.network.broadcast import KolibriInstanceListener
2324
from ..utils.network.broadcast import LOCAL_DOMAIN
25+
from ..utils.network.broadcast import LOCAL_NAME_BARE
26+
from ..utils.network.broadcast import LOCAL_NAME_DEVICE
2427
from ..utils.network.broadcast import NetworkDiscoveryBackend
2528
from ..utils.network.broadcast import SERVICE_TTL
2629
from ..utils.network.broadcast import SERVICE_TYPE
@@ -323,6 +326,8 @@ def setUp(self):
323326
enqueue_patcher.start()
324327
self.addCleanup(enqueue_patcher.stop)
325328
self.transport = ZeroconfNetworkDiscovery()
329+
# Captures the transport's local-name events (published on its own bus).
330+
self.listener = self.transport.add_listener(KolibriTestInstanceListener)
326331
self.on_add = mock.Mock()
327332
self.on_update = mock.Mock()
328333
self.on_remove = mock.Mock()
@@ -331,12 +336,23 @@ def setUp(self):
331336
)
332337
get_all_addresses_patcher.start()
333338
self.addCleanup(get_all_addresses_patcher.stop)
339+
# Default to "no default route" so tests don't open a real socket and
340+
# address selection falls back to the LAN-filtered addresses. Tests
341+
# exercising the outgoing-interface preference override this.
334342
outgoing_patcher = mock.patch(
335343
BROADCAST_MODULE + "get_outgoing_interface_address", return_value=None
336344
)
337-
outgoing_patcher.start()
345+
self.mock_outgoing_interface_address = outgoing_patcher.start()
338346
self.addCleanup(outgoing_patcher.stop)
339347

348+
def _register_with_device_name(self, device_name):
349+
self.transport.zeroconf = self.zeroconf
350+
self.instance.device_info = {"device_name": device_name}
351+
self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)(
352+
"primary"
353+
)
354+
self.transport.register(self.instance)
355+
340356
@mock.patch(BROADCAST_MODULE + "logger.info")
341357
@mock.patch(BROADCAST_MODULE + "Zeroconf")
342358
def test_register_opens_zeroconf_and_registers(self, mock_zeroconf, mock_logger):
@@ -383,6 +399,181 @@ def test_register_rename(self, mock_logger):
383399
service_info_unique, ttl=SERVICE_TTL
384400
)
385401

402+
@mock.patch(BROADCAST_MODULE + "logger.info")
403+
def test_register__local_names(self, mock_logger):
404+
self.transport.zeroconf = self.zeroconf
405+
service_info = mock.Mock(spec_set=ServiceInfo)("test")
406+
self.instance.to_service_info.return_value = service_info
407+
self.transport.register(self.instance)
408+
bare_label, bare_service = self.transport.local_names[LOCAL_NAME_BARE]
409+
self.assertEqual(BARE_LOCAL_LABEL, bare_label)
410+
self.assertEqual("kolibri.local.", bare_service.server)
411+
self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address)
412+
self.assertNotIn(LOCAL_NAME_DEVICE, self.transport.local_names)
413+
414+
@mock.patch(BROADCAST_MODULE + "get_all_addresses")
415+
@mock.patch(BROADCAST_MODULE + "logger.info")
416+
def test_register__local_names_lan_address_selection(
417+
self, mock_logger, mock_get_all_addresses
418+
):
419+
self.transport.zeroconf = self.zeroconf
420+
service_info = mock.Mock(spec_set=ServiceInfo)("test")
421+
self.instance.to_service_info.return_value = service_info
422+
cases = [
423+
(
424+
[MOCK_CGNAT_IP, MOCK_LINK_LOCAL_IP, MOCK_LAN_IP],
425+
socket.inet_aton(MOCK_LAN_IP),
426+
),
427+
([MOCK_CGNAT_IP], None),
428+
]
429+
for addresses, expected_address in cases:
430+
mock_get_all_addresses.return_value = addresses
431+
self.transport.register(self.instance)
432+
_, bare_service = self.transport.local_names[LOCAL_NAME_BARE]
433+
self.assertEqual(expected_address, bare_service.address)
434+
435+
@mock.patch(BROADCAST_MODULE + "get_all_addresses")
436+
@mock.patch(BROADCAST_MODULE + "logger.info")
437+
def test_register__prefers_outgoing_interface_address(
438+
self, mock_logger, mock_get_all_addresses
439+
):
440+
# On a multi-homed host both RFC1918 addresses survive the LAN filter,
441+
# so we must advertise the default-route interface, not whichever one
442+
# sorts first. Reproduces the QA-reported case where a Docker-bridge
443+
# address was handed to LAN peers that couldn't reach it.
444+
self.transport.zeroconf = self.zeroconf
445+
self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)(
446+
"primary"
447+
)
448+
mock_get_all_addresses.return_value = [MOCK_SECONDARY_LAN_IP, MOCK_LAN_IP]
449+
self.mock_outgoing_interface_address.return_value = MOCK_LAN_IP
450+
self.transport.register(self.instance)
451+
_, bare_service = self.transport.local_names[LOCAL_NAME_BARE]
452+
self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address)
453+
454+
@mock.patch(BROADCAST_MODULE + "get_all_addresses")
455+
@mock.patch(BROADCAST_MODULE + "logger.info")
456+
def test_register__falls_back_when_outgoing_not_lan_reachable(
457+
self, mock_logger, mock_get_all_addresses
458+
):
459+
# If the outgoing interface isn't LAN-reachable (e.g. a VPN default
460+
# route filtered out as CGNAT), fall back to a LAN-filtered address
461+
# rather than advertising the unreachable one.
462+
self.transport.zeroconf = self.zeroconf
463+
self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)(
464+
"primary"
465+
)
466+
mock_get_all_addresses.return_value = [MOCK_CGNAT_IP, MOCK_LAN_IP]
467+
self.mock_outgoing_interface_address.return_value = MOCK_CGNAT_IP
468+
self.transport.register(self.instance)
469+
_, bare_service = self.transport.local_names[LOCAL_NAME_BARE]
470+
self.assertEqual(socket.inet_aton(MOCK_LAN_IP), bare_service.address)
471+
472+
@mock.patch(BROADCAST_MODULE + "logger.info")
473+
def test_register__device_name_alias(self, mock_logger):
474+
self._register_with_device_name("Tony's Laptop")
475+
device_label, device_service = self.transport.local_names[LOCAL_NAME_DEVICE]
476+
self.assertEqual("tonyslaptop", device_label)
477+
self.assertEqual("tonyslaptop.local.", device_service.server)
478+
479+
@mock.patch(BROADCAST_MODULE + "logger.info")
480+
def test_register__device_name_alias_empty_slug(self, mock_logger):
481+
self._register_with_device_name(" ")
482+
self.assertNotIn(LOCAL_NAME_DEVICE, self.transport.local_names)
483+
484+
@mock.patch(BROADCAST_MODULE + "logger.info")
485+
def test_register__local_name_conflict_skipped(self, mock_logger):
486+
# These aliases make no attempt to stay unique: if the name is already
487+
# claimed on the network, we skip ours rather than renaming or crashing
488+
# the whole broadcast.
489+
self.transport.zeroconf = self.zeroconf
490+
self.instance.to_service_info.return_value = mock.Mock(spec_set=ServiceInfo)(
491+
"primary"
492+
)
493+
# primary registers fine; the bare alias is already claimed
494+
self.zeroconf.register_service.side_effect = [None, NonUniqueNameException()]
495+
self.transport.register(self.instance) # must not raise
496+
self.assertNotIn(LOCAL_NAME_BARE, self.transport.local_names)
497+
self.assertEqual([], self.transport.local_hostnames)
498+
499+
def test_local_hostnames(self):
500+
self._register_with_device_name("My Device")
501+
self.assertEqual(
502+
{"kolibri.local", "mydevice.local"},
503+
set(self.transport.local_hostnames),
504+
)
505+
self.assertEqual(
506+
{"kolibri.local", "mydevice.local"},
507+
set(self.listener.mock.update_local_names.call_args[0][0]),
508+
)
509+
510+
@mock.patch(BROADCAST_MODULE + "get_all_addresses")
511+
@mock.patch(BROADCAST_MODULE + "logger.info")
512+
def test_renew__local_names_follow_lan_address_change(
513+
self, mock_logger, mock_get_all_addresses
514+
):
515+
mock_get_all_addresses.return_value = [MOCK_LAN_IP]
516+
self._register_with_device_name("Some Name")
517+
518+
new_lan_ip = "192.168.1.9"
519+
mock_get_all_addresses.return_value = [new_lan_ip]
520+
self.transport.renew()
521+
522+
_, bare_service = self.transport.local_names[LOCAL_NAME_BARE]
523+
self.assertEqual(socket.inet_aton(new_lan_ip), bare_service.address)
524+
525+
@mock.patch(BROADCAST_MODULE + "logger.info")
526+
def test_renew__device_name_changed(self, mock_logger):
527+
self._register_with_device_name("Old Name")
528+
old_service = self.transport.local_names[LOCAL_NAME_DEVICE][1]
529+
530+
self.instance.device_info = {"device_name": "New Name"}
531+
self.transport.renew()
532+
533+
self.zeroconf.unregister_service.assert_any_call(old_service)
534+
new_label, new_service = self.transport.local_names[LOCAL_NAME_DEVICE]
535+
self.assertEqual("newname", new_label)
536+
self.assertEqual("newname.local.", new_service.server)
537+
self.zeroconf.register_service.assert_any_call(new_service, ttl=new_service.ttl)
538+
539+
@mock.patch(BROADCAST_MODULE + "logger.info")
540+
def test_renew__device_name_changed_to_empty_slug(self, mock_logger):
541+
self._register_with_device_name("Old Name")
542+
old_service = self.transport.local_names[LOCAL_NAME_DEVICE][1]
543+
544+
self.instance.device_info = {"device_name": " "}
545+
self.transport.renew()
546+
547+
self.zeroconf.unregister_service.assert_any_call(old_service)
548+
self.assertNotIn(LOCAL_NAME_DEVICE, self.transport.local_names)
549+
550+
@mock.patch(BROADCAST_MODULE + "logger.info")
551+
def test_renew__device_name_unchanged(self, mock_logger):
552+
self._register_with_device_name("Same Name")
553+
self.zeroconf.register_service.reset_mock()
554+
self.zeroconf.unregister_service.reset_mock()
555+
556+
self.transport.renew()
557+
558+
self.zeroconf.unregister_service.assert_not_called()
559+
self.zeroconf.register_service.assert_not_called()
560+
# 1 for the primary instance (pre-existing, unchanged renew() logic)
561+
# + 2 for the bare and device aliases, both re-announced with fresh port/ttl
562+
self.assertEqual(3, self.zeroconf.update_service.call_count)
563+
564+
def test_unregister__local_names(self):
565+
self.instance.service_info = mock.Mock(spec_set=ServiceInfo)("test")
566+
self._register_with_device_name("Some Name")
567+
bare_service = self.transport.local_names[LOCAL_NAME_BARE][1]
568+
device_service = self.transport.local_names[LOCAL_NAME_DEVICE][1]
569+
570+
self.transport.unregister()
571+
572+
self.zeroconf.unregister_service.assert_any_call(bare_service)
573+
self.zeroconf.unregister_service.assert_any_call(device_service)
574+
self.assertEqual({}, self.transport.local_names)
575+
self.listener.mock.update_local_names.assert_called_with([])
576+
386577
@pytest.mark.skipif(ZEROCONF_NEEDS_UPDATE, reason="Needs updated Zeroconf")
387578
@mock.patch(BROADCAST_MODULE + "ZeroconfNetworkDiscovery.renew")
388579
def test_update_returns_false_when_addresses_unchanged(self, mock_renew):

0 commit comments

Comments
 (0)