diff --git a/kolibri/conftest.py b/kolibri/conftest.py index f410839493b..42b0c4cf5e5 100644 --- a/kolibri/conftest.py +++ b/kolibri/conftest.py @@ -1,5 +1,7 @@ +import ipaddress import os import shutil +import socket import pytest @@ -8,6 +10,72 @@ # referenced in pytest.ini TEMP_KOLIBRI_HOME = "./.pytest_kolibri_home" +# Network access guard +# +# Blocks all network access to non-loopback addresses for the entire test +# run, so that no test can ever depend on (or be broken by) an external +# service. Any attempt to resolve or connect to a non-local host raises +# BlockedNetworkAccessError pointing at the offending call. +# +# Tests that exercise code making outbound HTTP requests should mock at the +# requests session level, e.g. by patching ``requests.Session.request`` - +# see kolibri/core/discovery/test/helpers.py for reusable helpers. + +LOOPBACK_HOSTNAMES = {"localhost", "localhost.localdomain", "ip6-localhost", ""} + +_real_getaddrinfo = socket.getaddrinfo +_real_socket_connect = socket.socket.connect + + +class BlockedNetworkAccessError(RuntimeError): + pass + + +def _is_local_host(host): + if host is None: + return True + if isinstance(host, bytes): + host = host.decode("utf-8", "replace") + host = host.lower().rstrip(".") + if host in LOOPBACK_HOSTNAMES: + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + # A non-local hostname that would require DNS resolution + return False + return ip.is_loopback or ip.is_unspecified + + +def _block(api, host, port): + raise BlockedNetworkAccessError( + "A test attempted real network access via {api} to {host}:{port}. " + "Tests must not make requests to external services - mock them " + "instead, e.g. by patching requests.Session.request " + "(see kolibri/core/discovery/test/helpers.py).".format( + api=api, host=host, port=port + ) + ) + + +def _guarded_getaddrinfo(host, port, *args, **kwargs): + if not _is_local_host(host): + _block("socket.getaddrinfo", host, port) + return _real_getaddrinfo(host, port, *args, **kwargs) + + +def _guarded_socket_connect(sock, address): + if sock.family in (socket.AF_INET, socket.AF_INET6) and isinstance(address, tuple): + host = address[0] + if not _is_local_host(host): + _block("socket.connect", host, address[1] if len(address) > 1 else "") + return _real_socket_connect(sock, address) + + +def pytest_configure(config): + socket.getaddrinfo = _guarded_getaddrinfo + socket.socket.connect = _guarded_socket_connect + @pytest.fixture(autouse=True) def clear_process_cache(): diff --git a/kolibri/core/test/test_key_urls.py b/kolibri/core/test/test_key_urls.py index 3020605de9e..b6bb929c136 100644 --- a/kolibri/core/test/test_key_urls.py +++ b/kolibri/core/test/test_key_urls.py @@ -1,3 +1,4 @@ +import requests from django.conf import settings from django.urls import reverse from django.urls.exceptions import NoReverseMatch @@ -12,6 +13,20 @@ from kolibri.core.auth.test.test_api import FacilityFactory from kolibri.core.auth.test.test_api import FacilityUserFactory from kolibri.core.device.translation import get_settings_language +from kolibri.core.discovery.test.helpers import mock_response + + +def mock_external_request(session, method, url, *args, **kwargs): + """ + Give any outbound HTTP request a successful empty response, so that views + proxying to external services (the Kolibri Data Portal token validation, + the Studio remote channel lookup) can be smoke tested without depending + on those services. + """ + response = mock_response(200) + response.url = url + response.json.return_value = [] + return response class BeforeDeviceProvisionTests(APITestCase): @@ -215,7 +230,9 @@ def check_urls(urlpatterns, prefix=""): # noqa: C901 with patch( "kolibri.core.webpack.hooks.WebpackBundleHook.bundle", return_value=[] - ), patch("kolibri.core.webpack.hooks.WebpackBundleHook.get_by_unique_id"): + ), patch( + "kolibri.core.webpack.hooks.WebpackBundleHook.get_by_unique_id" + ), patch.object(requests.Session, "request", mock_external_request): from kolibri.deployment.default.urls import urlpatterns check_urls(urlpatterns)