|
6 | 6 |
|
7 | 7 | import itertools |
8 | 8 | import logging |
9 | | -import threading |
| 9 | +import os |
| 10 | +import socket |
10 | 11 | import time |
11 | 12 | import unittest |
12 | 13 | import unittest.mock |
| 14 | +from typing import cast |
13 | 15 |
|
14 | | - |
15 | | -import pytest |
16 | 16 | import zeroconf as r |
17 | 17 | from zeroconf import core |
18 | 18 |
|
| 19 | +from . import has_working_ipv6, _inject_response |
| 20 | + |
19 | 21 | log = logging.getLogger('zeroconf') |
20 | 22 | original_logging_level = logging.NOTSET |
21 | 23 |
|
@@ -52,3 +54,158 @@ def test_reaper(self): |
52 | 54 | assert entries_with_cache != original_entries |
53 | 55 | assert record_with_10s_ttl in entries |
54 | 56 | assert record_with_1s_ttl not in entries |
| 57 | + |
| 58 | + |
| 59 | +class Framework(unittest.TestCase): |
| 60 | + def test_launch_and_close(self): |
| 61 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) |
| 62 | + rv.close() |
| 63 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) |
| 64 | + rv.close() |
| 65 | + |
| 66 | + def test_launch_and_close_context_manager(self): |
| 67 | + with r.Zeroconf(interfaces=r.InterfaceChoice.All) as rv: |
| 68 | + assert rv.done is False |
| 69 | + assert rv.done is True |
| 70 | + |
| 71 | + with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: |
| 72 | + assert rv.done is False |
| 73 | + assert rv.done is True |
| 74 | + |
| 75 | + def test_launch_and_close_unicast(self): |
| 76 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, unicast=True) |
| 77 | + rv.close() |
| 78 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, unicast=True) |
| 79 | + rv.close() |
| 80 | + |
| 81 | + def test_close_multiple_times(self): |
| 82 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) |
| 83 | + rv.close() |
| 84 | + rv.close() |
| 85 | + |
| 86 | + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') |
| 87 | + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') |
| 88 | + def test_launch_and_close_v4_v6(self): |
| 89 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) |
| 90 | + rv.close() |
| 91 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) |
| 92 | + rv.close() |
| 93 | + |
| 94 | + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') |
| 95 | + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') |
| 96 | + def test_launch_and_close_v6_only(self): |
| 97 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) |
| 98 | + rv.close() |
| 99 | + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) |
| 100 | + rv.close() |
| 101 | + |
| 102 | + def test_handle_response(self): |
| 103 | + def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: |
| 104 | + ttl = 120 |
| 105 | + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) |
| 106 | + |
| 107 | + if service_state_change == r.ServiceStateChange.Updated: |
| 108 | + generated.add_answer_at_time( |
| 109 | + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 |
| 110 | + ) |
| 111 | + return r.DNSIncoming(generated.packet()) |
| 112 | + |
| 113 | + if service_state_change == r.ServiceStateChange.Removed: |
| 114 | + ttl = 0 |
| 115 | + |
| 116 | + generated.add_answer_at_time( |
| 117 | + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 |
| 118 | + ) |
| 119 | + generated.add_answer_at_time( |
| 120 | + r.DNSService( |
| 121 | + service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server |
| 122 | + ), |
| 123 | + 0, |
| 124 | + ) |
| 125 | + generated.add_answer_at_time( |
| 126 | + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 |
| 127 | + ) |
| 128 | + generated.add_answer_at_time( |
| 129 | + r.DNSAddress( |
| 130 | + service_server, |
| 131 | + r._TYPE_A, |
| 132 | + r._CLASS_IN | r._CLASS_UNIQUE, |
| 133 | + ttl, |
| 134 | + socket.inet_aton(service_address), |
| 135 | + ), |
| 136 | + 0, |
| 137 | + ) |
| 138 | + |
| 139 | + return r.DNSIncoming(generated.packet()) |
| 140 | + |
| 141 | + def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: |
| 142 | + """Mock an incoming message for the case where the packet is split.""" |
| 143 | + ttl = 120 |
| 144 | + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) |
| 145 | + generated.add_answer_at_time( |
| 146 | + r.DNSAddress( |
| 147 | + service_server, |
| 148 | + r._TYPE_A, |
| 149 | + r._CLASS_IN | r._CLASS_UNIQUE, |
| 150 | + ttl, |
| 151 | + socket.inet_aton(service_address), |
| 152 | + ), |
| 153 | + 0, |
| 154 | + ) |
| 155 | + generated.add_answer_at_time( |
| 156 | + r.DNSService( |
| 157 | + service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server |
| 158 | + ), |
| 159 | + 0, |
| 160 | + ) |
| 161 | + return r.DNSIncoming(generated.packet()) |
| 162 | + |
| 163 | + service_name = 'name._type._tcp.local.' |
| 164 | + service_type = '_type._tcp.local.' |
| 165 | + service_server = 'ash-2.local.' |
| 166 | + service_text = b'path=/~paulsm/' |
| 167 | + service_address = '10.0.1.2' |
| 168 | + |
| 169 | + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) |
| 170 | + |
| 171 | + try: |
| 172 | + # service added |
| 173 | + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) |
| 174 | + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) |
| 175 | + assert dns_text is not None |
| 176 | + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' |
| 177 | + all_dns_text = zeroconf.cache.get_all_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) |
| 178 | + assert [dns_text] == all_dns_text |
| 179 | + |
| 180 | + # https://tools.ietf.org/html/rfc6762#section-10.2 |
| 181 | + # Instead of merging this new record additively into the cache in addition |
| 182 | + # to any previous records with the same name, rrtype, and rrclass, |
| 183 | + # all old records with that name, rrtype, and rrclass that were received |
| 184 | + # more than one second ago are declared invalid, |
| 185 | + # and marked to expire from the cache in one second. |
| 186 | + time.sleep(1.1) |
| 187 | + |
| 188 | + # service updated. currently only text record can be updated |
| 189 | + service_text = b'path=/~humingchun/' |
| 190 | + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) |
| 191 | + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) |
| 192 | + assert dns_text is not None |
| 193 | + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' |
| 194 | + |
| 195 | + time.sleep(1.1) |
| 196 | + |
| 197 | + # The split message only has a SRV and A record. |
| 198 | + # This should not evict TXT records from the cache |
| 199 | + _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) |
| 200 | + time.sleep(1.1) |
| 201 | + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) |
| 202 | + assert dns_text is not None |
| 203 | + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' |
| 204 | + |
| 205 | + # service removed |
| 206 | + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) |
| 207 | + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) |
| 208 | + assert dns_text is None |
| 209 | + |
| 210 | + finally: |
| 211 | + zeroconf.close() |
0 commit comments