From 98bfd97dddc484d3420da37d6270b8a1ee6c3586 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 08:10:41 +0000 Subject: [PATCH 1/4] test: reproduce missing private CA trust configuration --- tests/test_tls_trust.py | 191 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 tests/test_tls_trust.py diff --git a/tests/test_tls_trust.py b/tests/test_tls_trust.py new file mode 100644 index 0000000..1d25186 --- /dev/null +++ b/tests/test_tls_trust.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import shutil +import ssl +import subprocess +import threading +from collections.abc import Iterator +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import certifi +import httpx +import pytest + +from durable_workflow import serializer +from durable_workflow.client import Client +from durable_workflow.retry_policy import TransportRetryPolicy +from durable_workflow.sync import Client as SyncClient +from durable_workflow.worker import Worker +from tests.test_runtime_external_payload_transport import FakeRuntimePayloadServer + + +@dataclass +class TLSEndpoint: + url: str + mismatch_url: str + ca_file: Path + runtime: FakeRuntimePayloadServer + + def context(self) -> ssl.SSLContext: + context = ssl.create_default_context(cafile=certifi.where()) + context.load_verify_locations(cafile=str(self.ca_file)) + return context + + +@pytest.fixture(scope="module") +def tls_endpoint(tmp_path_factory: pytest.TempPathFactory) -> Iterator[TLSEndpoint]: + openssl = shutil.which("openssl") + if openssl is None: + pytest.skip("TLS controls require the OpenSSL CLI (required in supported-version CI).") + directory = tmp_path_factory.mktemp("private-ca") + commands = [ + ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=SDK Test CA", + "-addext", "basicConstraints=critical,CA:TRUE", "-keyout", "ca.key", "-out", "ca.pem"], + ["req", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=localhost", + "-keyout", "server.key", "-out", "server.csr"], + ["x509", "-req", "-in", "server.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", + "-days", "1", "-extfile", "server.ext", "-out", "server.pem"], + ] + (directory / "server.ext").write_text("subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth\n") + for arguments in commands: + subprocess.run([openssl, *arguments], cwd=directory, check=True, capture_output=True, timeout=15) + runtime = FakeRuntimePayloadServer() + runtime.responses[("GET", "/api/health")] = {"status": "serving"} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.reply() + + def do_POST(self) -> None: + self.reply() + + def reply(self) -> None: + request = httpx.Request( + self.command, "https://localhost" + self.path, + headers=dict(self.headers.items()), content=self.rfile.read(int(self.headers.get("Content-Length", 0))), + ) + response = runtime.handler(request) + self.send_response(response.status_code) + for name, value in response.headers.items(): + self.send_header(name, value) + self.end_headers() + self.wfile.write(response.content) + + def log_message(self, format: str, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain(str(directory / "server.pem"), str(directory / "server.key")) + server.socket = server_context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True) + thread.start() + port = server.server_address[1] + try: + yield TLSEndpoint(f"https://localhost:{port}", f"https://127.0.0.1:{port}", directory / "ca.pem", runtime) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + assert not thread.is_alive() + + +def options(endpoint: TLSEndpoint) -> dict[str, Any]: + return {"token": "runtime-role-token", "namespace": "billing", "timeout": 2, + "retry_policy": TransportRetryPolicy(max_attempts=1)} + + +async def test_default_client_refuses_an_untrusted_private_ca( + tls_endpoint: TLSEndpoint, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + async with Client(tls_endpoint.url, **options(tls_endpoint)) as client: + polling = client.health() + with pytest.raises(httpx.ConnectError, match="CERTIFICATE_VERIFY_FAILED"): + await polling + + +async def test_explicit_private_ca_context_preserves_public_roots(tls_endpoint: TLSEndpoint) -> None: + context = ssl.create_default_context(cafile=certifi.where()) + public_roots = set(context.get_ca_certs(binary_form=True)) + context.load_verify_locations(cafile=str(tls_endpoint.ca_file)) + assert public_roots < set(context.get_ca_certs(binary_form=True)) + async with Client(tls_endpoint.url, tls_context=context, **options(tls_endpoint)) as client: + assert await client.health() == {"status": "serving"} + assert client.tls_context is context + + +async def test_explicit_context_still_refuses_a_hostname_mismatch(tls_endpoint: TLSEndpoint) -> None: + async with Client(tls_endpoint.mismatch_url, tls_context=tls_endpoint.context(), **options(tls_endpoint)) as client: + polling = client.health() + with pytest.raises(httpx.ConnectError, match="CERTIFICATE_VERIFY_FAILED"): + await polling + + +def test_synchronous_client_uses_the_supplied_context(tls_endpoint: TLSEndpoint) -> None: + with SyncClient(tls_endpoint.url, tls_context=tls_endpoint.context(), **options(tls_endpoint)) as client: + assert client.health() == {"status": "serving"} + + +async def test_query_clone_and_nexus_view_preserve_the_same_context(tls_endpoint: TLSEndpoint) -> None: + context = tls_endpoint.context() + async with Client(tls_endpoint.url, tls_context=context, **options(tls_endpoint)) as client: + worker = Worker(client, task_queue="orders") + async with worker._clone_client_for_query_tasks() as clone: + assert clone.tls_context is context + assert await clone.health() == {"status": "serving"} + scoped = client._nexus_target_client("another-namespace") + assert scoped.tls_context is context + assert scoped._http is client._http + assert await scoped.health() == {"status": "serving"} + assert client.namespace == "billing" + + +async def test_worker_poll_and_payload_upload_fetch_use_verified_https(tls_endpoint: TLSEndpoint) -> None: + runtime = tls_endpoint.runtime + runtime.responses[("POST", "/api/worker/query-tasks/poll")] = {"task": None} + async with Client(tls_endpoint.url, tls_context=tls_endpoint.context(), **options(tls_endpoint)) as client: + assert await client.poll_query_task(worker_id="test-worker", task_queue="orders", timeout=0.05) is None + expected = {"customer": "synthetic", "value": "x" * 200} + wire = await client._externalize_runtime_payloads( + serializer.envelope(expected), worker=False, + transport=await client._runtime_external_payload_transport(), uploaded={}, slot=("arguments",), + ) + assert "external_payload" in wire + decoded = await client._resolve_runtime_payloads( + wire, worker=False, transport=await client._runtime_external_payload_transport(), resolved={}, + ) + assert serializer.decode_envelope(decoded) == expected + assert runtime.upload_count == runtime.fetch_count == 1 + + +async def test_default_environment_ca_selection_remains_supported( + tls_endpoint: TLSEndpoint, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SSL_CERT_FILE", str(tls_endpoint.ca_file)) + async with Client(tls_endpoint.url, **options(tls_endpoint)) as client: + assert await client.health() == {"status": "serving"} + + +@pytest.mark.parametrize("disable_hostname,disable_certificates", [(True, False), (True, True)]) +@pytest.mark.parametrize("client_class", [Client, SyncClient]) +def test_contexts_with_disabled_verification_are_rejected( + disable_hostname: bool, disable_certificates: bool, client_class: type, +) -> None: + context = ssl.create_default_context(cafile=certifi.where()) + if disable_hostname: + context.check_hostname = False + if disable_certificates: + context.verify_mode = ssl.CERT_NONE + with pytest.raises(ValueError, match="certificate and hostname verification"): + client_class("https://runtime.example", tls_context=context) + + +@pytest.mark.parametrize("value", [False, True, "private-ca.pem"]) +def test_context_option_refuses_non_context_values(value: object) -> None: + with pytest.raises(TypeError, match="SSLContext"): + Client("https://runtime.example", tls_context=value) # type: ignore[arg-type] From 49108fc1f576b469821c2db9c42ab136665960db Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 08:19:15 +0000 Subject: [PATCH 2/4] feat: accept explicit verified TLS contexts across Python clients --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 6 +++++ CONTRIBUTING.md | 4 ++++ docs/reference/client.md | 43 ++++++++++++++++++++++++++++++++++ docs/sdk-reference.md | 10 ++++++++ src/durable_workflow/client.py | 21 ++++++++++++++++- src/durable_workflow/sync.py | 6 +++++ src/durable_workflow/worker.py | 1 + tests/test_tls_trust.py | 16 +++++++++---- 9 files changed, 103 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4655fe2..ac57000 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,7 @@ jobs: shell: bash run: | set -o pipefail + command -v openssl # Leave room within the 15-minute job for bounded failure diagnostics. timeout --signal=INT --kill-after=10s 13m \ pytest tests/ -m "not integration" -vv \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d48ae..f2e5c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Explicit verified `tls_context` configuration for async and synchronous + clients, including cloned query clients and runtime payload transfers. + Applications can add a private CA while retaining certifi's public roots. + ## [2.9.1] - 2026-10-22 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45d95f6..70110e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ Run Ruff, mypy, and focused pytest coverage for changed code. +The TLS tests use the OpenSSL CLI to create a throwaway private CA and run a +local HTTPS endpoint. Install `openssl` to run those controls locally; the +supported-version CI requires it before running tests. + Replay and payload-codec fixes also follow the organization [regression-corpus contract](https://github.com/durable-workflow/.github/tree/main/regression-corpus). Put minimal replay histories in the existing golden-history format when diff --git a/docs/reference/client.md b/docs/reference/client.md index 64023ca..2fe1c77 100644 --- a/docs/reference/client.md +++ b/docs/reference/client.md @@ -1,5 +1,48 @@ # Client +## Trust a private certificate authority + +The `tls_context` option is available in unreleased SDK source and will be +included in the next package release. + +Pass a verifying `ssl.SSLContext` to configure trust for this client without +changing trust for other clients in the process. Start with certifi's public +roots, then add your private CA: + +```python +import ssl + +import certifi + +from durable_workflow import Client + +context = ssl.create_default_context(cafile=certifi.where()) +context.load_verify_locations(cafile="company-ca.pem") + +client = Client("https://workflow.example.internal", tls_context=context) +``` + +The synchronous `durable_workflow.sync.Client` accepts the same option. Worker +query clients and Nexus namespace views reuse the context. Control and worker +requests, including runtime-owned external payload upload and fetch, use the +same configured transport. Separately configured storage drivers own their +storage-service transports. + +An explicit context replaces httpx's default root selection. It does not +automatically add public roots: the example above keeps them by loading +certifi before adding the private CA. Contexts with disabled certificate or +hostname verification are rejected. Treat the supplied context as read-only +after client construction. + +With no explicit context, the existing httpx behavior remains: trust comes +from the installed certifi bundle, or from `SSL_CERT_FILE` / `SSL_CERT_DIR` +when those environment variables are set. Installing a CA only into the OS +store does not update certifi. `SSL_CERT_FILE` replaces the root bundle for +httpx clients throughout the process; a private-CA-only file can therefore +remove public trust. Prefer a per-client context when applications need +different trust configurations. An explicit context takes precedence over +those environment root settings. + `Client.execute_nexus_operation(...)` calls a registered Nexus service operation through the service-catalog API and returns `NexusOperationResult`. Workflow authors should normally yield `ctx.call_nexus_service(...)`; the diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index bbb4ddd..54c4e85 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -966,6 +966,16 @@ compatibility manifest. A client configured with only the opposite role's token still fails before transport for actual worker or control operations; use `token` when one shared credential intentionally authorizes both planes. +## TLS trust + +Unreleased SDK source accepts `tls_context=ssl_context` on async and synchronous +clients. Use a verifying `ssl.SSLContext` initialized with certifi's public +roots, then load your private CA. Worker query clones and runtime-owned payload +transfers reuse that trust configuration. See the +[private CA recipe](reference/client.md#trust-a-private-certificate-authority) +for root selection and `SSL_CERT_FILE` / `SSL_CERT_DIR` behavior. Published +packages gain this option with the next SDK release. + ## Activity heartbeat refusals Use `await activity.context().heartbeat(details)` in an async activity or diff --git a/src/durable_workflow/client.py b/src/durable_workflow/client.py index 97287b3..d1d69b5 100644 --- a/src/durable_workflow/client.py +++ b/src/durable_workflow/client.py @@ -26,6 +26,7 @@ import math import os import re +import ssl import time import uuid import warnings @@ -1631,6 +1632,10 @@ class Client: The client owns one `httpx.AsyncClient` connection pool. Use it as an async context manager or call `aclose()` when finished. + + ``tls_context`` selects an explicit trust configuration. It must require + certificate and hostname verification. Treat the context as read-only after + construction; worker query clones and Nexus namespace views reuse it. """ def __init__( @@ -1642,6 +1647,7 @@ def __init__( worker_token: str | None = None, namespace: str = "default", timeout: float = 60.0, + tls_context: ssl.SSLContext | None = None, retry_policy: TransportRetryPolicy | None = None, metrics: MetricsRecorder | None = None, payload_size_limit_bytes: int = serializer.DEFAULT_PAYLOAD_SIZE_BYTES, @@ -1652,6 +1658,11 @@ def __init__( external_storage_cache: ExternalPayloadCache | None = None, telemetry: ExecutionTelemetry | None = None, ) -> None: + if tls_context is not None: + if not isinstance(tls_context, ssl.SSLContext): + raise TypeError("tls_context must be an ssl.SSLContext") + if tls_context.verify_mode != ssl.CERT_REQUIRED or not tls_context.check_hostname: + raise ValueError("tls_context must enable certificate and hostname verification") self.base_url = _normalize_base_url(base_url) self.token = token self.control_token = control_token @@ -1660,6 +1671,7 @@ def __init__( self.telemetry = telemetry self._observability_capability = False self.timeout = timeout + self._tls_context = tls_context self.retry_policy = retry_policy or TransportRetryPolicy() self.metrics = metrics or NOOP_METRICS self.payload_size_warning_config = ( @@ -1679,7 +1691,9 @@ def __init__( if external_storage_cache is not None else ExternalPayloadCache() ) - self._http = httpx.AsyncClient(base_url=self.base_url, timeout=timeout) + self._http = httpx.AsyncClient( + base_url=self.base_url, timeout=timeout, verify=tls_context if tls_context is not None else True, + ) self._cluster_info: dict[str, Any] | None = None self._cluster_info_lock = asyncio.Lock() self._runtime_external_payload_transport_cache: ( @@ -1688,6 +1702,11 @@ def __init__( self._runtime_external_payload_transport_resolved = False self._workflow_payload_observers: dict[str, Callable[[dict[str, Any]], Awaitable[None]]] = {} + @property + def tls_context(self) -> ssl.SSLContext | None: + """The explicit TLS context, or ``None`` for httpx's default trust selection.""" + return self._tls_context + async def aclose(self) -> None: """Close the underlying ``httpx`` connection pool. diff --git a/src/durable_workflow/sync.py b/src/durable_workflow/sync.py index da48428..499aef6 100644 --- a/src/durable_workflow/sync.py +++ b/src/durable_workflow/sync.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import ssl from collections.abc import Coroutine from typing import Any, TypeVar @@ -266,6 +267,9 @@ class Client: The client keeps one event loop for its lifetime so its async HTTP connection pool and returned handles always run on the loop that owns them. + + ``tls_context`` forwards the same verified trust configuration accepted by + the async client. Treat the supplied context as read-only during use. """ def __init__( @@ -277,6 +281,7 @@ def __init__( worker_token: str | None = None, namespace: str = "default", timeout: float = 60.0, + tls_context: ssl.SSLContext | None = None, retry_policy: TransportRetryPolicy | None = None, metrics: MetricsRecorder | None = None, external_storage: ExternalStorageDriver | None = None, @@ -290,6 +295,7 @@ def __init__( worker_token=worker_token, namespace=namespace, timeout=timeout, + tls_context=tls_context, retry_policy=retry_policy, metrics=metrics, external_storage=external_storage, diff --git a/src/durable_workflow/worker.py b/src/durable_workflow/worker.py index 7bbc75e..b13b10a 100644 --- a/src/durable_workflow/worker.py +++ b/src/durable_workflow/worker.py @@ -4078,6 +4078,7 @@ def _clone_client_for_query_tasks(self) -> Client: worker_token=self.client.worker_token, namespace=self.client.namespace, timeout=getattr(self.client, "timeout", 60.0), + tls_context=self.client.tls_context, retry_policy=self.client.retry_policy, metrics=self.metrics, payload_size_limit_bytes=( diff --git a/tests/test_tls_trust.py b/tests/test_tls_trust.py index 1d25186..41ad23c 100644 --- a/tests/test_tls_trust.py +++ b/tests/test_tls_trust.py @@ -43,13 +43,17 @@ def tls_endpoint(tmp_path_factory: pytest.TempPathFactory) -> Iterator[TLSEndpoi directory = tmp_path_factory.mktemp("private-ca") commands = [ ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=SDK Test CA", - "-addext", "basicConstraints=critical,CA:TRUE", "-keyout", "ca.key", "-out", "ca.pem"], + "-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-keyout", "ca.key", "-out", "ca.pem"], ["req", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=localhost", "-keyout", "server.key", "-out", "server.csr"], ["x509", "-req", "-in", "server.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-days", "1", "-extfile", "server.ext", "-out", "server.pem"], ] - (directory / "server.ext").write_text("subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth\n") + (directory / "server.ext").write_text( + "subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth\nbasicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n" + ) for arguments in commands: subprocess.run([openssl, *arguments], cwd=directory, check=True, capture_output=True, timeout=15) runtime = FakeRuntimePayloadServer() @@ -148,7 +152,8 @@ async def test_query_clone_and_nexus_view_preserve_the_same_context(tls_endpoint async def test_worker_poll_and_payload_upload_fetch_use_verified_https(tls_endpoint: TLSEndpoint) -> None: runtime = tls_endpoint.runtime runtime.responses[("POST", "/api/worker/query-tasks/poll")] = {"task": None} - async with Client(tls_endpoint.url, tls_context=tls_endpoint.context(), **options(tls_endpoint)) as client: + context = tls_endpoint.context() + async with Client(tls_endpoint.url, tls_context=context, **options(tls_endpoint)) as client: assert await client.poll_query_task(worker_id="test-worker", task_queue="orders", timeout=0.05) is None expected = {"customer": "synthetic", "value": "x" * 200} wire = await client._externalize_runtime_payloads( @@ -156,11 +161,14 @@ async def test_worker_poll_and_payload_upload_fetch_use_verified_https(tls_endpo transport=await client._runtime_external_payload_transport(), uploaded={}, slot=("arguments",), ) assert "external_payload" in wire + # A fresh client has no upload cache and must fetch the actual HTTPS bytes. + async with Client(tls_endpoint.url, tls_context=context, **options(tls_endpoint)) as client: decoded = await client._resolve_runtime_payloads( wire, worker=False, transport=await client._runtime_external_payload_transport(), resolved={}, ) assert serializer.decode_envelope(decoded) == expected - assert runtime.upload_count == runtime.fetch_count == 1 + assert runtime.upload_count == 1 + assert runtime.fetch_count == 1 async def test_default_environment_ca_selection_remains_supported( From 6719b1b869b3818e1a2c031fc52cffb54b16b188 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 08:23:28 +0000 Subject: [PATCH 3/4] test: distinguish hostname and certificate-mode refusals --- tests/test_tls_trust.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_tls_trust.py b/tests/test_tls_trust.py index 41ad23c..4ef0c46 100644 --- a/tests/test_tls_trust.py +++ b/tests/test_tls_trust.py @@ -58,6 +58,7 @@ def tls_endpoint(tmp_path_factory: pytest.TempPathFactory) -> Iterator[TLSEndpoi subprocess.run([openssl, *arguments], cwd=directory, check=True, capture_output=True, timeout=15) runtime = FakeRuntimePayloadServer() runtime.responses[("GET", "/api/health")] = {"status": "serving"} + runtime.responses[("POST", "/api/worker/query-tasks/poll")] = {"task": None} class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: @@ -126,7 +127,7 @@ async def test_explicit_private_ca_context_preserves_public_roots(tls_endpoint: async def test_explicit_context_still_refuses_a_hostname_mismatch(tls_endpoint: TLSEndpoint) -> None: async with Client(tls_endpoint.mismatch_url, tls_context=tls_endpoint.context(), **options(tls_endpoint)) as client: polling = client.health() - with pytest.raises(httpx.ConnectError, match="CERTIFICATE_VERIFY_FAILED"): + with pytest.raises(httpx.ConnectError, match="mismatch"): await polling @@ -141,7 +142,7 @@ async def test_query_clone_and_nexus_view_preserve_the_same_context(tls_endpoint worker = Worker(client, task_queue="orders") async with worker._clone_client_for_query_tasks() as clone: assert clone.tls_context is context - assert await clone.health() == {"status": "serving"} + assert await clone.poll_query_task(worker_id=worker.worker_id, task_queue="orders", timeout=0.05) is None scoped = client._nexus_target_client("another-namespace") assert scoped.tls_context is context assert scoped._http is client._http @@ -151,7 +152,6 @@ async def test_query_clone_and_nexus_view_preserve_the_same_context(tls_endpoint async def test_worker_poll_and_payload_upload_fetch_use_verified_https(tls_endpoint: TLSEndpoint) -> None: runtime = tls_endpoint.runtime - runtime.responses[("POST", "/api/worker/query-tasks/poll")] = {"task": None} context = tls_endpoint.context() async with Client(tls_endpoint.url, tls_context=context, **options(tls_endpoint)) as client: assert await client.poll_query_task(worker_id="test-worker", task_queue="orders", timeout=0.05) is None @@ -179,16 +179,16 @@ async def test_default_environment_ca_selection_remains_supported( assert await client.health() == {"status": "serving"} -@pytest.mark.parametrize("disable_hostname,disable_certificates", [(True, False), (True, True)]) +@pytest.mark.parametrize("hostname,mode", [ + (False, ssl.CERT_REQUIRED), (False, ssl.CERT_NONE), (True, ssl.CERT_OPTIONAL), +]) @pytest.mark.parametrize("client_class", [Client, SyncClient]) def test_contexts_with_disabled_verification_are_rejected( - disable_hostname: bool, disable_certificates: bool, client_class: type, + hostname: bool, mode: ssl.VerifyMode, client_class: type, ) -> None: context = ssl.create_default_context(cafile=certifi.where()) - if disable_hostname: - context.check_hostname = False - if disable_certificates: - context.verify_mode = ssl.CERT_NONE + context.check_hostname = hostname + context.verify_mode = mode with pytest.raises(ValueError, match="certificate and hostname verification"): client_class("https://runtime.example", tls_context=context) From 12a49e1334c8a15f5eeb2745f4b2fa85ef966516 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 08:29:25 +0000 Subject: [PATCH 4/4] test: require TLS 1.2 on the private CA fixture server --- tests/test_tls_trust.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_tls_trust.py b/tests/test_tls_trust.py index 4ef0c46..40dfba8 100644 --- a/tests/test_tls_trust.py +++ b/tests/test_tls_trust.py @@ -84,6 +84,7 @@ def log_message(self, format: str, *args: object) -> None: server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = ssl.TLSVersion.TLSv1_2 server_context.load_cert_chain(str(directory / "server.pem"), str(directory / "server.key")) server.socket = server_context.wrap_socket(server.socket, server_side=True) thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True)