From e223c14590e432af5e4edcb1a29729b3dffa1001 Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Mon, 17 Aug 2026 19:39:05 +0000 Subject: [PATCH 1/9] SNOW-2912540: add mitmproxy capture fixture base Passive HTTPS-capture test infrastructure so telemetry integration tests can verify what Snowpark actually sends over the wire without mocking any backend response. tests/mitmproxy_client.py runs mitmdump as a real forward proxy (traffic reaches its real destination untouched); tests/mitmproxy_addon.py records every decrypted request to a JSON-lines file the client reads via get_requests()/wait_for_requests(), mirroring the shape of the connector world's WiremockClient. New conftest.py fixtures (_mitmproxy_session, mitmproxy, mitmproxy_session) wire proxy_host/proxy_port and REQUESTS_CA_BUNDLE into a real session/connection. test_mitmproxy_fixture.py proves the whole path end-to-end before the telemetry tests depend on it. --- tests/integ/conftest.py | 42 +++++++ tests/integ/test_mitmproxy_fixture.py | 21 ++++ tests/mitmproxy_addon.py | 27 +++++ tests/mitmproxy_client.py | 155 ++++++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 tests/integ/test_mitmproxy_fixture.py create mode 100644 tests/mitmproxy_addon.py create mode 100644 tests/mitmproxy_client.py diff --git a/tests/integ/conftest.py b/tests/integ/conftest.py index bd94206d21..7704cf70ad 100644 --- a/tests/integ/conftest.py +++ b/tests/integ/conftest.py @@ -19,6 +19,7 @@ setup_full_ast_validation_mode, ) from tests.integ.session_parameters import set_up_test_session_parameters +from tests.mitmproxy_client import MitmproxyClient from tests.parameters import CONNECTION_PARAMETERS from tests.utils import ( TEST_SCHEMA, @@ -402,6 +403,47 @@ def session( if validate_ast: close_full_ast_validation_mode(full_ast_validation_listener) + +@pytest.fixture(scope="session") +def _mitmproxy_session(): + """Start one mitmdump process per xdist worker and reuse it across tests.""" + client = MitmproxyClient().start() + try: + yield client + finally: + client.stop() + + +@pytest.fixture +def mitmproxy(_mitmproxy_session): + """Per-test mitmproxy handle backed by a session-scoped mitmdump process. + + Captured requests are cleared before each test; the process itself stays + up, saving the mitmdump startup cost per test. + """ + _mitmproxy_session.reset() + return _mitmproxy_session + + +@pytest.fixture +def mitmproxy_session(db_parameters, local_testing_mode, mitmproxy, monkeypatch): + """A Session whose traffic is routed through the mitmproxy fixture. + + Login/query/telemetry all reach the real account untouched -- the proxy + only observes traffic in transit, so no backend response is faked here. + """ + if local_testing_mode: + pytest.skip("mitmproxy capture requires a real network connection") + monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(mitmproxy.ca_cert_path)) + params = dict(db_parameters) + params["proxy_host"] = mitmproxy.proxy_host + params["proxy_port"] = mitmproxy.proxy_port + proxied_session = Session.builder.configs(params).create() + try: + yield proxied_session + finally: + proxied_session.close() + if (RUNNING_ON_GH or RUNNING_ON_JENKINS) and not local_testing_mode: clean_up_external_access_integration_resources() session.close() diff --git a/tests/integ/test_mitmproxy_fixture.py b/tests/integ/test_mitmproxy_fixture.py new file mode 100644 index 0000000000..083b6ce62a --- /dev/null +++ b/tests/integ/test_mitmproxy_fixture.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# +"""Self-test proving the mitmproxy capture fixture works end-to-end. + +Exists so a break in the fixture itself (proxy routing, CA trust, capture) +fails loudly here instead of silently as an empty-results false pass in the +telemetry tests that depend on it. +""" + + +def test_mitmproxy_captures_login_request(mitmproxy_session, mitmproxy): + # mitmproxy_session already completed login by the time this test body + # runs, so the request should already be captured. + requests = mitmproxy.wait_for_requests( + r"/session/v1/login-request", min_count=1, timeout=10.0 + ) + assert ( + len(requests) >= 1 + ), "Expected the session's login request to be captured by the proxy" diff --git a/tests/mitmproxy_addon.py b/tests/mitmproxy_addon.py new file mode 100644 index 0000000000..fa632905fd --- /dev/null +++ b/tests/mitmproxy_addon.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# +"""mitmdump addon that records every request mitmproxy sees. + +Loaded via `mitmdump -s tests/mitmproxy_addon.py`. Appends one JSON line per +request to the file at MITMPROXY_CAPTURE_OUTPUT_PATH; MitmproxyClient reads +that file to answer get_requests()/wait_for_requests(). Traffic is otherwise +untouched -- this addon never returns a response, so requests continue to +their real destination. +""" +import json +import os + +_OUTPUT_PATH = os.environ["MITMPROXY_CAPTURE_OUTPUT_PATH"] + + +def request(flow) -> None: + record = { + "method": flow.request.method, + "url": flow.request.pretty_url, + "headers": dict(flow.request.headers), + "body": flow.request.text, + } + with open(_OUTPUT_PATH, "a") as f: + f.write(json.dumps(record) + "\n") diff --git a/tests/mitmproxy_client.py b/tests/mitmproxy_client.py new file mode 100644 index 0000000000..7b20048826 --- /dev/null +++ b/tests/mitmproxy_client.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# +"""Passive HTTPS capture for integration tests, via mitmproxy. + +Unlike a stub server, this never fabricates a response: it runs `mitmdump` as +a real forward proxy so login/query/telemetry traffic reaches its actual +destination untouched, while tests/mitmproxy_addon.py records every request +mitmproxy decrypts to a JSON-lines file this client reads. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import socket +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + + +class MitmproxyClient: + _ADDON_PATH = Path(__file__).parent / "mitmproxy_addon.py" + + def __init__(self) -> None: + self.host = "127.0.0.1" + self.port: Optional[int] = None + self._process: Optional[subprocess.Popen] = None + fd, path = tempfile.mkstemp(prefix="mitmproxy_capture_", suffix=".jsonl") + os.close(fd) + self._output_path = Path(path) + + def start(self) -> "MitmproxyClient": + if shutil.which("mitmdump") is None: + raise RuntimeError( + "mitmdump not found on PATH. Install with: pip install mitmproxy" + ) + self.port = self._find_free_port() + env = {**os.environ, "MITMPROXY_CAPTURE_OUTPUT_PATH": str(self._output_path)} + self._process = subprocess.Popen( + [ + "mitmdump", + "--listen-host", + self.host, + "--listen-port", + str(self.port), + "-s", + str(self._ADDON_PATH), + ], + env=env, + # Redirect to DEVNULL: an unread stdout/stderr pipe can deadlock + # mitmdump's logging thread once its buffer fills. + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._wait_for_port() + self._wait_for_ca_cert() + return self + + @staticmethod + def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + def _wait_for_port(self, timeout: float = 30.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if self._process.poll() is not None: + raise RuntimeError( + f"mitmdump exited early with code {self._process.returncode}" + ) + try: + with socket.create_connection((self.host, self.port), timeout=0.5): + return + except OSError: + time.sleep(0.2) + raise RuntimeError("mitmdump did not become ready in time") + + def _wait_for_ca_cert(self, timeout: float = 10.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if self.ca_cert_path.exists(): + return + time.sleep(0.2) + raise RuntimeError("mitmproxy CA cert was not generated in time") + + @property + def ca_cert_path(self) -> Path: + return Path.home() / ".mitmproxy" / "mitmproxy-ca-cert.pem" + + @property + def proxy_host(self) -> str: + return self.host + + @property + def proxy_port(self) -> int: + return self.port + + def reset(self) -> None: + self._output_path.write_text("") + + def get_requests(self, url_path_pattern: str) -> List[Dict[str, Any]]: + if not self._output_path.exists(): + return [] + matches = [] + for line in self._output_path.read_text().splitlines(): + if not line: + continue + entry = json.loads(line) + if re.search(url_path_pattern, entry["url"]): + matches.append(entry) + return matches + + def wait_for_requests( + self, + url_path_pattern: str, + min_count: int = 1, + timeout: float = 2.0, + poll_interval: float = 0.1, + ) -> List[Dict[str, Any]]: + """Poll until at least `min_count` requests matching the pattern arrive. + + Useful for asserting on requests sent asynchronously (e.g. telemetry). + """ + deadline = time.time() + timeout + result: List[Dict[str, Any]] = [] + while time.time() < deadline: + result = self.get_requests(url_path_pattern) + if len(result) >= min_count: + return result + time.sleep(poll_interval) + return result + + def stop(self) -> None: + if self._process is not None: + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + self._process = None + if self._output_path.exists(): + self._output_path.unlink() + + def __enter__(self) -> "MitmproxyClient": + return self.start() + + def __exit__(self, *exc_info: Any) -> None: + self.stop() From 70639ecb6f73e658d9076ae00f542073eb41f535 Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Thu, 20 Aug 2026 18:17:19 +0000 Subject: [PATCH 2/9] SNOW-2912540: silence flake8 F401 false-positive on typing imports Dict/List/Optional are genuinely used (return-type and attribute annotations under `from __future__ import annotations`), but this repo's pinned flake8==5.0.4/pyflakes doesn't detect usage within these specific annotation forms -- confirmed by comparing against _internal/code_generation.py, the only other file combining `from __future__ import annotations` with typing generics, which only uses them in plain parameter/local-variable annotations, never return-type generics like this file does. --- tests/mitmproxy_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mitmproxy_client.py b/tests/mitmproxy_client.py index 7b20048826..507508fd03 100644 --- a/tests/mitmproxy_client.py +++ b/tests/mitmproxy_client.py @@ -20,7 +20,7 @@ import tempfile import time from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional # noqa: F401 class MitmproxyClient: From b2e6212386cf8ba429ba6d791138dcb6cdef871d Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Thu, 20 Aug 2026 18:26:27 +0000 Subject: [PATCH 3/9] SNOW-2912540: modernize mitmproxy_client.py's type hints per pyupgrade Superseded my own previous fix attempt (noqa: F401) -- that treated the wrong symptom. Reproduced the actual CI failure locally: pyupgrade (which runs before flake8 in this repo's pre-commit chain) rewrites Optional[X]/ List[X]/Dict[X] to X | None/list[X]/dict[X] and drops string forward-refs, since from __future__ import annotations makes both unnecessary here. Any file modification during pre-commit's CI check run fails the job regardless of what flake8 itself would say, so the noqa comment never addressed the actual failure. Verified clean with a full local `pre-commit run --all-files`. --- tests/mitmproxy_client.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/mitmproxy_client.py b/tests/mitmproxy_client.py index 507508fd03..1b9cf185d6 100644 --- a/tests/mitmproxy_client.py +++ b/tests/mitmproxy_client.py @@ -20,7 +20,7 @@ import tempfile import time from pathlib import Path -from typing import Any, Dict, List, Optional # noqa: F401 +from typing import Any class MitmproxyClient: @@ -28,13 +28,13 @@ class MitmproxyClient: def __init__(self) -> None: self.host = "127.0.0.1" - self.port: Optional[int] = None - self._process: Optional[subprocess.Popen] = None + self.port: int | None = None + self._process: subprocess.Popen | None = None fd, path = tempfile.mkstemp(prefix="mitmproxy_capture_", suffix=".jsonl") os.close(fd) self._output_path = Path(path) - def start(self) -> "MitmproxyClient": + def start(self) -> MitmproxyClient: if shutil.which("mitmdump") is None: raise RuntimeError( "mitmdump not found on PATH. Install with: pip install mitmproxy" @@ -104,7 +104,7 @@ def proxy_port(self) -> int: def reset(self) -> None: self._output_path.write_text("") - def get_requests(self, url_path_pattern: str) -> List[Dict[str, Any]]: + def get_requests(self, url_path_pattern: str) -> list[dict[str, Any]]: if not self._output_path.exists(): return [] matches = [] @@ -122,13 +122,13 @@ def wait_for_requests( min_count: int = 1, timeout: float = 2.0, poll_interval: float = 0.1, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Poll until at least `min_count` requests matching the pattern arrive. Useful for asserting on requests sent asynchronously (e.g. telemetry). """ deadline = time.time() + timeout - result: List[Dict[str, Any]] = [] + result: list[dict[str, Any]] = [] while time.time() < deadline: result = self.get_requests(url_path_pattern) if len(result) >= min_count: @@ -148,7 +148,7 @@ def stop(self) -> None: if self._output_path.exists(): self._output_path.unlink() - def __enter__(self) -> "MitmproxyClient": + def __enter__(self) -> MitmproxyClient: return self.start() def __exit__(self, *exc_info: Any) -> None: From 056e4409ae7e70973138d74e7f73df8f8cf1f12e Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 08:11:00 +0000 Subject: [PATCH 4/9] SNOW-2912540: add mitmproxy to development test dependencies tests/mitmproxy_client.py requires the mitmdump binary on PATH; without it, CI fails every test depending on the mitmproxy fixture with "RuntimeError: mitmdump not found on PATH." --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 25d8590197..3e2d6fd749 100644 --- a/setup.py +++ b/setup.py @@ -73,6 +73,7 @@ "psutil", # testing for telemetry "lxml", # used in XML reader unit tests "pyarrow", # used in dataframe reader tests + "mitmproxy", # wire-level telemetry capture in tests/mitmproxy_client.py ] MODIN_DEVELOPMENT_REQUIREMENTS = [ # Snowpark pandas 3rd party library testing. Cap the scipy version because From 189f87c953385b1c1b91892abb7f76552baa19e4 Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 08:41:48 +0000 Subject: [PATCH 5/9] SNOW-2912540: pin mitmproxy version to avoid resolving an ancient release Unpinned "mitmproxy" resolved to 0.15 in CI, whose transitive urwid<1.4 dependency fails to build under modern setuptools (use_2to3 is invalid). Match the version range the universal-driver's own CI already pins for the same tool (mitmproxy>=11,<13). --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3e2d6fd749..b15a6d0bb7 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,7 @@ "psutil", # testing for telemetry "lxml", # used in XML reader unit tests "pyarrow", # used in dataframe reader tests - "mitmproxy", # wire-level telemetry capture in tests/mitmproxy_client.py + "mitmproxy>=11,<13", # wire-level telemetry capture in tests/mitmproxy_client.py ] MODIN_DEVELOPMENT_REQUIREMENTS = [ # Snowpark pandas 3rd party library testing. Cap the scipy version because From 27f69d7a9f2fb9ddad3835e0b7ddad90679f1365 Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 08:50:46 +0000 Subject: [PATCH 6/9] SNOW-2912540: read MITMPROXY_CAPTURE_OUTPUT_PATH lazily inside request() This file lives under tests/, so pytest's doctest/AST-scanning jobs import it as part of their broad module walk (regardless of test-file naming conventions), executing the module-level os.environ[...] access outside the mitmdump subprocess context where that var is actually set -- failing collection with KeyError across every "Doctest AST"/"Test AST Encoding" job. Defer the lookup to when the hook actually fires. --- tests/mitmproxy_addon.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/mitmproxy_addon.py b/tests/mitmproxy_addon.py index fa632905fd..5c121d3b6a 100644 --- a/tests/mitmproxy_addon.py +++ b/tests/mitmproxy_addon.py @@ -13,15 +13,14 @@ import json import os -_OUTPUT_PATH = os.environ["MITMPROXY_CAPTURE_OUTPUT_PATH"] - def request(flow) -> None: + output_path = os.environ["MITMPROXY_CAPTURE_OUTPUT_PATH"] record = { "method": flow.request.method, "url": flow.request.pretty_url, "headers": dict(flow.request.headers), "body": flow.request.text, } - with open(_OUTPUT_PATH, "a") as f: + with open(output_path, "a") as f: f.write(json.dumps(record) + "\n") From 15798a99a16200d7e86fad9dfd7b113d319f6a67 Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 12:29:16 +0000 Subject: [PATCH 7/9] SNOW-2912540: restore session.close() dropped from the session fixture's teardown A bad hunk placement in the mitmproxy_session fixture's initial commit moved this fixture's teardown tail (clean_up_external_access_integration_resources() + session.close()) into mitmproxy_session's finally block, where `session` was undefined. The later NameError fix removed the dangling reference but never restored it here, so `session` (module-scoped, used by nearly every integration test) never actually closed. Since Session.close() is the only thing that removes a session from the process-wide active-session registry, every module leaked one session per xdist worker -- once a worker crossed two leaked sessions, any bare udf()/getOrCreate() call raised MORE_THAN_ONE_ACTIVE_SESSIONS for the rest of that worker's queue. --- tests/integ/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integ/conftest.py b/tests/integ/conftest.py index 7704cf70ad..6bc7d180a2 100644 --- a/tests/integ/conftest.py +++ b/tests/integ/conftest.py @@ -403,6 +403,10 @@ def session( if validate_ast: close_full_ast_validation_mode(full_ast_validation_listener) + if (RUNNING_ON_GH or RUNNING_ON_JENKINS) and not local_testing_mode: + clean_up_external_access_integration_resources() + session.close() + @pytest.fixture(scope="session") def _mitmproxy_session(): From 1ef49c3bd107500fe977ece2c6dd61cdcf3f6f87 Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 12:56:31 +0000 Subject: [PATCH 8/9] SNOW-2912540: disable OCSP checks for mitmproxy-routed test sessions mitmproxy re-signs TLS traffic with its own CA-issued leaf certificate to decrypt and observe it. The connector's revocation check treats that certificate as genuinely revoked/unvalidatable (254007), not as an unreachable-responder case ocsp_fail_open would paper over, so every mitmproxy_session connection fails outright: "Could not connect to Snowflake backend after 11 attempt(s)." Disable revocation checking for this proxy-routed session only -- it never touches a real leaf cert, so there is nothing for OCSP/CRL to meaningfully validate. --- tests/integ/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integ/conftest.py b/tests/integ/conftest.py index 6bc7d180a2..8e71cb9e94 100644 --- a/tests/integ/conftest.py +++ b/tests/integ/conftest.py @@ -442,6 +442,11 @@ def mitmproxy_session(db_parameters, local_testing_mode, mitmproxy, monkeypatch) params = dict(db_parameters) params["proxy_host"] = mitmproxy.proxy_host params["proxy_port"] = mitmproxy.proxy_port + # mitmproxy re-signs TLS traffic with its own CA-issued leaf cert, which + # has no real OCSP/CRL revocation record -- disable revocation checking + # rather than relying on ocsp_fail_open, since mitmproxy's cert triggers + # a hard "revoked" failure, not a soft "responder unreachable" one. + params["disable_ocsp_checks"] = True proxied_session = Session.builder.configs(params).create() try: yield proxied_session From 4c2c0ccb9ca1f89c2fa0feda6fc7a8ae456b201c Mon Sep 17 00:00:00 2001 From: Filip Pawlowski Date: Fri, 21 Aug 2026 13:33:55 +0000 Subject: [PATCH 9/9] SNOW-2912540: remove misplaced session-fixture teardown code from mitmproxy_session The bad hunk placement from mitmproxy_session's initial commit (fixed for try_add_log_to_batch's teardown by restoring session.close() to the `session` fixture in the previous commit) left a stale, still-broken copy of that same teardown tail inside mitmproxy_session's own finally block. There, `session` resolves to the module-level `session` fixture *function* (mitmproxy_session has no local named `session`), so tearing down any mitmproxy_session-based test raised AttributeError: 'function' object has no attribute 'close'. mitmproxy_session already closes its own proxied_session two lines above; this block never belonged here. --- tests/integ/conftest.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/integ/conftest.py b/tests/integ/conftest.py index 8e71cb9e94..1633b38d29 100644 --- a/tests/integ/conftest.py +++ b/tests/integ/conftest.py @@ -453,10 +453,6 @@ def mitmproxy_session(db_parameters, local_testing_mode, mitmproxy, monkeypatch) finally: proxied_session.close() - if (RUNNING_ON_GH or RUNNING_ON_JENKINS) and not local_testing_mode: - clean_up_external_access_integration_resources() - session.close() - @pytest.fixture(scope="function") def profiler_session(