From 1cd6ef5b5cebc00bffae5a9b0dbc8a0df3f656e4 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 5 Jun 2026 13:55:18 -0700 Subject: [PATCH] Block external network access in Python tests The key urls walker test has been making real HTTP requests to the Kolibri Data Portal (via the portal validate_token proxy view) and to Studio (via the remote channel viewset) on every run since 2019, failing whenever those services have an outage. Patch socket.getaddrinfo and socket.socket.connect in conftest so any attempt to reach a non-loopback address raises a clear error naming the offending host, and mock the outbound requests in the key urls test at the requests session level. Co-Authored-By: Claude Opus 4.8 (1M context) --- kolibri/conftest.py | 68 ++++++++++++++++++++++++++++++ kolibri/core/test/test_key_urls.py | 19 ++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) 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)