diff --git a/tests/__init__.py b/tests/__init__.py index 7163137..fe2b220 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,34 @@ -"""Unit tests for Update-time.""" +"""Unit tests for Update-time. + +If a test reaches the network, the live registry answers it, so the test passes or fails on data nobody wrote +down. Importing this package refuses the network to every test in it. The refusal is a `RuntimeError` rather +than a connection error because `update_time.io.fetch` turns a `requests` exception into a logged `None`, so a +connection error would leave an unmocked request answered with `None` rather than failing the test. +""" + +from typing import TYPE_CHECKING, NoReturn +from unittest.mock import patch + +if TYPE_CHECKING: + import socket + + +def _refuse(address: object) -> NoReturn: + """Raise the refusal, naming the address the test tried to reach.""" + message = f"test tried to reach the network at {address}" + raise RuntimeError(message) + + +def _refuse_connection(_self: socket.socket, address: object) -> NoReturn: + _refuse(address) + + +def _refuse_resolution(host: object, *_args: object, **_kwargs: object) -> NoReturn: + """Refuse to resolve the host name, whatever port, family, and flags the lookup passes with it.""" + _refuse(host) + + +# A request to a host name is refused at the lookup, before it reaches the resolver; one to a literal address needs +# no lookup and is refused at the socket. Neither patch is ever stopped: no test may lift the refusal. +patch("socket.socket.connect", _refuse_connection).start() +patch("socket.getaddrinfo", _refuse_resolution).start() diff --git a/tests/test_network_guard.py b/tests/test_network_guard.py new file mode 100644 index 0000000..96ac90d --- /dev/null +++ b/tests/test_network_guard.py @@ -0,0 +1,31 @@ +"""Unit tests for the guard that refuses the network.""" + +import socket +import unittest +from unittest.mock import Mock + +from update_time.io.fetch import fetch + + +class NetworkGuardTest(unittest.TestCase): + """Unit tests for the refusal of network access.""" + + def test_connecting_a_socket_is_refused(self): + """Test that connecting a socket raises an error naming the address it tried to reach.""" + with socket.socket() as sock, self.assertRaises(RuntimeError) as raised: + sock.connect(("127.0.0.1", 9)) + self.assertEqual(str(raised.exception), "test tried to reach the network at ('127.0.0.1', 9)") + + def test_resolving_a_host_name_is_refused(self): + """Test that resolving a host name raises an error naming the host it tried to reach.""" + with self.assertRaises(RuntimeError) as raised: + socket.getaddrinfo("update-time.invalid", 443) + self.assertEqual(str(raised.exception), "test tried to reach the network at update-time.invalid") + + def test_an_unmocked_request_fails_the_test(self): + """Test that a request raises the refusal instead of fetch logging it as a network error and returning None.""" + logger = Mock() + with self.assertRaises(RuntimeError) as raised: + fetch("https://update-time.invalid/", logger) + self.assertEqual(str(raised.exception), "test tried to reach the network at update-time.invalid") + self.assertEqual(logger.mock_calls, [])