diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index 47b78dfb4..740f9a547 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -17,17 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -170,15 +159,6 @@ version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -233,22 +213,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "core-foundation" version = "0.10.1" @@ -265,15 +229,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -307,16 +262,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "darling" version = "0.20.11" @@ -366,21 +311,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "deflate64" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" - -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -444,17 +374,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - [[package]] name = "dirs" version = "6.0.0" @@ -725,16 +644,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.0.2" @@ -843,15 +752,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.3.1" @@ -1104,15 +1004,6 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "instant" version = "0.1.13" @@ -1211,26 +1102,6 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" -[[package]] -name = "liblzma" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10bf66f4598dc77ff96677c8e763655494f00ff9c1cf79e2eb5bb07bc31f807d" -dependencies = [ - "liblzma-sys", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "libredox" version = "0.1.9" @@ -1406,12 +1277,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-traits" version = "0.2.19" @@ -1566,16 +1431,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - [[package]] name = "percent-encoding" version = "2.3.1" @@ -1663,18 +1518,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppmd-rust" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2416,17 +2259,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -2814,25 +2646,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - [[package]] name = "tinystr" version = "0.8.1" @@ -3117,12 +2930,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - [[package]] name = "typetag" version = "0.2.20" @@ -3729,20 +3536,6 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zerotrie" @@ -3783,23 +3576,12 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ - "aes", "arbitrary", "bzip2", - "constant_time_eq", "crc32fast", - "deflate64", "flate2", - "getrandom 0.3.3", - "hmac", "indexmap", - "liblzma", "memchr", - "pbkdf2", - "ppmd-rust", - "sha1", - "time", - "zeroize", "zopfli", "zstd", ] diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 042372d7b..4078190b9 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 042372d7b0e9931ff04dfac5d92740046ea13fb4 +Subproject commit 4078190b99ef16691512bc58e1da2d109419bc93 diff --git a/temporalio/bridge/src/envconfig.rs b/temporalio/bridge/src/envconfig.rs index 1651b4469..da93586c7 100644 --- a/temporalio/bridge/src/envconfig.rs +++ b/temporalio/bridge/src/envconfig.rs @@ -89,19 +89,14 @@ fn load_client_config_inner( py: Python, config_source: Option, config_file_strict: bool, - disable_file: bool, env_vars: Option>, ) -> PyResult { - let core_config = if disable_file { - CoreClientConfig::default() - } else { - let options = LoadClientConfigOptions { - config_source, - config_file_strict, - }; - core_load_client_config(options, env_vars.as_ref()) - .map_err(|e| ConfigError::new_err(format!("{e}")))? + let options = LoadClientConfigOptions { + config_source, + config_file_strict, }; + let core_config = core_load_client_config(options, env_vars.as_ref()) + .map_err(|e| ConfigError::new_err(format!("{e}")))?; core_config_to_dict(py, &core_config) } @@ -130,12 +125,11 @@ fn load_client_connect_config_inner( } #[pyfunction] -#[pyo3(signature = (path, data, disable_file, config_file_strict, env_vars = None))] +#[pyo3(signature = (path, data, config_file_strict, env_vars = None))] pub fn load_client_config( py: Python, path: Option, data: Option>, - disable_file: bool, config_file_strict: bool, env_vars: Option>, ) -> PyResult { @@ -153,7 +147,6 @@ pub fn load_client_config( py, config_source, config_file_strict, - disable_file, env_vars, ) } diff --git a/temporalio/envconfig.py b/temporalio/envconfig.py index ff68474cb..0dc14be65 100644 --- a/temporalio/envconfig.py +++ b/temporalio/envconfig.py @@ -24,7 +24,7 @@ class ClientConfigTLSDict(TypedDict, total=False): """Dictionary representation of TLS config for TOML.""" - disabled: bool + disabled: Optional[bool] server_name: str server_ca_cert: Mapping[str, str] client_cert: Mapping[str, str] @@ -105,8 +105,8 @@ class ClientConfigTLS: Experimental API. """ - disabled: bool = False - """If true, TLS is explicitly disabled.""" + disabled: Optional[bool] = None + """If True, TLS is explicitly disabled. If False, TLS is explicitly enabled. If None, TLS behavior was not configured.""" server_name: Optional[str] = None """SNI override.""" server_root_ca_cert: Optional[DataSource] = None @@ -119,7 +119,7 @@ class ClientConfigTLS: def to_dict(self) -> ClientConfigTLSDict: """Convert to a dictionary that can be used for TOML serialization.""" d: ClientConfigTLSDict = {} - if self.disabled: + if self.disabled is not None: d["disabled"] = self.disabled if self.server_name is not None: d["server_name"] = self.server_name @@ -138,7 +138,7 @@ def set_source( def to_connect_tls_config(self) -> Union[bool, temporalio.service.TLSConfig]: """Create a `temporalio.service.TLSConfig` from this profile.""" - if self.disabled: + if self.disabled is True: return False return temporalio.service.TLSConfig( @@ -154,7 +154,7 @@ def from_dict(d: Optional[ClientConfigTLSDict]) -> Optional[ClientConfigTLS]: if not d: return None return ClientConfigTLS( - disabled=d.get("disabled", False), + disabled=d.get("disabled"), server_name=d.get("server_name"), # Note: Bridge uses snake_case, but TOML uses kebab-case which is # converted to snake_case. Core has server_ca_cert, client_key. @@ -238,7 +238,10 @@ def to_client_connect_config(self) -> ClientConnectConfig: config["namespace"] = self.namespace if self.api_key is not None: config["api_key"] = self.api_key + # Enable TLS with default TLS options + config["tls"] = True if self.tls is not None: + # Use specified TLS options config["tls"] = self.tls.to_connect_tls_config() if self.grpc_meta: config["rpc_metadata"] = self.grpc_meta @@ -333,7 +336,6 @@ def from_dict( def load( *, config_source: Optional[DataSource] = None, - disable_file: bool = False, config_file_strict: bool = False, override_env_vars: Optional[Mapping[str, str]] = None, ) -> ClientConfig: @@ -348,8 +350,6 @@ def load( config_source: If present, this is used as the configuration source instead of default file locations. This can be a path to the file or the string/byte contents of the file. - disable_file: If true, file loading is disabled. This is only used - when ``config_source`` is not present. config_file_strict: If true, will TOML file parsing will error on unrecognized keys. override_env_vars: The environment variables to use for locating the @@ -364,7 +364,6 @@ def load( loaded_profiles = _bridge_envconfig.load_client_config( path=path, data=data, - disable_file=disable_file, config_file_strict=config_file_strict, env_vars=override_env_vars, ) diff --git a/tests/test_envconfig.py b/tests/test_envconfig.py index 775c59400..013904098 100644 --- a/tests/test_envconfig.py +++ b/tests/test_envconfig.py @@ -223,10 +223,13 @@ def test_load_profile_api_key_enables_tls(tmp_path: Path): config_file.write_text(config_toml) profile = ClientConfigProfile.load(config_source=config_file) assert profile.api_key == "my-key" - assert profile.tls is not None + # No TLS object should have been created + assert profile.tls is None config = profile.to_client_connect_config() - assert config.get("tls") + # Expect to_client_connect_config call to set TLS to True + # due to presence of api key. + assert config.get("tls") is True assert config.get("api_key") == "my-key" @@ -285,13 +288,6 @@ def test_load_profiles_discovery(tmp_path: Path, monkeypatch): # type: ignore[r assert "default" in client_config.profiles -def test_load_profiles_disable_file(): - """Test load_profiles with file loading disabled.""" - # With no env vars, should be empty - client_config = ClientConfig.load(disable_file=True, override_env_vars={}) - assert not client_config.profiles - - def test_load_profiles_strict_mode_fail(tmp_path: Path): """Test that strict mode fails on unrecognized keys.""" config_file = tmp_path / "config.toml" @@ -519,7 +515,6 @@ def test_client_config_profile_to_from_dict(): namespace="some-namespace", api_key="some-api-key", tls=ClientConfigTLS( - disabled=False, server_name="some-server-name", server_root_ca_cert=b"ca-cert-data", client_cert=Path("/path/to/client.crt"), @@ -530,7 +525,7 @@ def test_client_config_profile_to_from_dict(): profile_dict = profile.to_dict() - # Check dict representation. Note that disabled=False is not in the dict. + # Check dict representation. Note that disabled=None is not in the dict. expected_dict = { "address": "some-address", "namespace": "some-namespace", @@ -557,7 +552,6 @@ def test_client_config_profile_to_from_dict(): namespace="some-namespace", api_key="some-api-key", tls=ClientConfigTLS( - disabled=False, server_name="some-server-name", server_root_ca_cert="ca-cert-data", # Was bytes, now str client_cert=Path("/path/to/client.crt"), @@ -614,3 +608,424 @@ def test_client_config_to_from_dict(): assert empty_config_dict == {} new_empty_config = ClientConfig.from_dict(empty_config_dict) assert empty_config == new_empty_config + + +def test_grpc_metadata_normalization_from_toml(): + """Test that gRPC metadata keys get normalized from TOML.""" + toml_config = textwrap.dedent( + """ + [profile.default] + address = "localhost:7233" + namespace = "default" + + [profile.default.grpc_meta] + "Custom-Header" = "custom-value" + "ANOTHER_HEADER_KEY" = "another-value" + "mixed_Case-header" = "mixed-value" + """ + ) + + profile = ClientConfigProfile.load(config_source=toml_config) + + # Keys should be normalized: uppercase -> lowercase, underscores -> hyphens + assert profile.grpc_meta["custom-header"] == "custom-value" + assert profile.grpc_meta["another-header-key"] == "another-value" + assert profile.grpc_meta["mixed-case-header"] == "mixed-value" + + # Original case variations should not exist + assert "Custom-Header" not in profile.grpc_meta + assert "ANOTHER_HEADER_KEY" not in profile.grpc_meta + assert "mixed_Case-header" not in profile.grpc_meta + + config = profile.to_client_connect_config() + rpc_metadata = config.get("rpc_metadata") + assert rpc_metadata is not None + assert rpc_metadata["custom-header"] == "custom-value" + assert rpc_metadata["another-header-key"] == "another-value" + + +def test_grpc_metadata_deletion_via_empty_env_value(base_config_file: Path): + """Test that empty environment variable values delete existing gRPC metadata.""" + env = { + # Empty value should remove the header + "TEMPORAL_GRPC_META_CUSTOM_HEADER": "", + # Non-empty value should set the header + "TEMPORAL_GRPC_META_NEW_HEADER": "new-value", + } + profile = ClientConfigProfile.load( + config_source=base_config_file, profile="custom", override_env_vars=env + ) + + # custom-header should be removed by empty env value + assert "custom-header" not in profile.grpc_meta + # new-header should be added + assert profile.grpc_meta["new-header"] == "new-value" + + config = profile.to_client_connect_config() + rpc_metadata = config.get("rpc_metadata") + if rpc_metadata: + assert "custom-header" not in rpc_metadata + assert rpc_metadata["new-header"] == "new-value" + + +def test_default_profile_not_found_returns_empty_profile(): + """Test that requesting missing 'default' profile returns empty profile instead of error.""" + toml_config = textwrap.dedent( + """ + [profile.existing] + address = "my-address" + """ + ) + profile = ClientConfigProfile.load(config_source=toml_config) + assert profile.address is None + assert profile.namespace is None + assert profile.api_key is None + assert not profile.grpc_meta + assert profile.tls is None + + +def test_tls_conflict_across_sources_path_in_toml_data_in_env(): + """Test error when cert path in TOML conflicts with cert data in env var.""" + toml_config = textwrap.dedent( + """ + [profile.default] + address = "localhost:7233" + [profile.default.tls] + client_cert_path = "/path/to/cert" + """ + ) + + env = {"TEMPORAL_TLS_CLIENT_CERT_DATA": "cert-data-from-env"} + + with pytest.raises( + RuntimeError, + match="Cannot specify cert data via TEMPORAL_TLS_CLIENT_CERT_DATA when cert path is already specified", + ): + ClientConfigProfile.load(config_source=toml_config, override_env_vars=env) + + +def test_tls_conflict_across_sources_data_in_toml_path_in_env(): + """Test error when cert data in TOML conflicts with cert path in env var.""" + toml_config = textwrap.dedent( + """ + [profile.default] + address = "localhost:7233" + [profile.default.tls] + client_cert_data = "cert-data-from-toml" + """ + ) + + env = {"TEMPORAL_TLS_CLIENT_CERT_PATH": "/path/from/env"} + + with pytest.raises( + RuntimeError, + match="Cannot specify cert path via TEMPORAL_TLS_CLIENT_CERT_PATH when cert data is already specified", + ): + ClientConfigProfile.load(config_source=toml_config, override_env_vars=env) + + +def test_load_client_connect_options_convenience_api(base_config_file: Path): + """Test the convenience API for loading client connect configuration.""" + # Test default profile with file + config = ClientConfig.load_client_connect_config(config_file=str(base_config_file)) + assert config.get("target_host") == "default-address" + assert config.get("namespace") == "default-namespace" + + # Test with environment overrides + env = {"TEMPORAL_NAMESPACE": "env-override-namespace"} + config_with_env = ClientConfig.load_client_connect_config( + config_file=str(base_config_file), override_env_vars=env + ) + assert config_with_env.get("target_host") == "default-address" + assert config_with_env.get("namespace") == "env-override-namespace" + + # Test with specific profile + config_custom = ClientConfig.load_client_connect_config( + profile="custom", config_file=str(base_config_file) + ) + assert config_custom.get("target_host") == "custom-address" + assert config_custom.get("namespace") == "custom-namespace" + assert config_custom.get("api_key") == "custom-api-key" + + +def test_load_client_connect_options_e2e_validation(): + """Test comprehensive end-to-end configuration loading with all features.""" + toml_content = textwrap.dedent( + """ + [profile.production] + address = "prod.temporal.com:443" + namespace = "production-ns" + api_key = "prod-api-key" + + [profile.production.tls] + server_name = "prod.temporal.com" + server_ca_cert_data = "prod-ca-cert" + + [profile.production.grpc_meta] + authorization = "Bearer prod-token" + "x-custom-header" = "prod-value" + """ + ) + + env_overrides = { + "TEMPORAL_GRPC_META_X_ENVIRONMENT": "production", + "TEMPORAL_TLS_SERVER_NAME": "override.temporal.com", + } + + config = ClientConfig.load_client_connect_config( + profile="production", + config_file=None, # Use config_source directly + override_env_vars=env_overrides, + disable_file=True, # Load from config_source instead + ) + + # First load the profile to get the raw config, then convert + profile = ClientConfigProfile.load( + profile="production", + config_source=toml_content, + override_env_vars=env_overrides, + ) + config = profile.to_client_connect_config() + + # Validate all configuration aspects + assert config.get("target_host") == "prod.temporal.com:443" + assert config.get("namespace") == "production-ns" + assert config.get("api_key") == "prod-api-key" + + # TLS configuration (API key should auto-enable TLS) + assert config.get("tls") is not None + tls_config = config.get("tls") + assert isinstance(tls_config, TLSConfig) + assert tls_config.domain == "override.temporal.com" # Env override + assert tls_config.server_root_ca_cert == b"prod-ca-cert" + + # gRPC metadata with normalization and env overrides + assert config.get("rpc_metadata") is not None + rpc_metadata = config.get("rpc_metadata") + assert rpc_metadata is not None + assert rpc_metadata["authorization"] == "Bearer prod-token" + assert rpc_metadata["x-custom-header"] == "prod-value" + assert rpc_metadata["x-environment"] == "production" # From env + + +async def test_e2e_basic_development_profile_client_connection(client: Client): + """Test basic development profile with actual client connection.""" + # Get connection details from the fixture client + target_host = client.service_client.config.target_host + namespace = client.namespace + + toml_content = textwrap.dedent( + f""" + [profile.development] + address = "{target_host}" + namespace = "{namespace}" + + [profile.development.grpc_meta] + "x-test-source" = "envconfig-python-dev" + """ + ) + + profile = ClientConfigProfile.load( + profile="development", config_source=toml_content + ) + + config = profile.to_client_connect_config() + + # Create actual Temporal client using envconfig + new_client = await Client.connect(**config) + + # Verify client configuration matches envconfig + assert new_client.service_client.config.target_host == target_host + assert new_client.namespace == namespace + if new_client.service_client.config.rpc_metadata: + assert ( + new_client.service_client.config.rpc_metadata["x-test-source"] + == "envconfig-python-dev" + ) + + +async def test_e2e_production_tls_api_key_client_connection(client: Client): + """Test production profile with TLS and API key with actual client connection.""" + # Get connection details from the fixture client + target_host = client.service_client.config.target_host + + toml_content = textwrap.dedent( + f""" + [profile.production] + address = "{target_host}" + namespace = "production-namespace" + api_key = "prod-api-key-123" + + [profile.production.tls] + disabled = true + + [profile.production.grpc_meta] + authorization = "Bearer prod-token" + "x-environment" = "production" + """ + ) + + profile = ClientConfigProfile.load(profile="production", config_source=toml_content) + + config = profile.to_client_connect_config() + + # Create TLS-enabled client with API key + new_client = await Client.connect(**config) + + # Verify production configuration + assert new_client.service_client.config.target_host == target_host + assert new_client.namespace == "production-namespace" + assert new_client.service_client.config.api_key == "prod-api-key-123" + if new_client.service_client.config.rpc_metadata: + assert ( + new_client.service_client.config.rpc_metadata["authorization"] + == "Bearer prod-token" + ) + assert ( + new_client.service_client.config.rpc_metadata["x-environment"] + == "production" + ) + + +async def test_e2e_environment_overrides_client_connection(client: Client): + """Test environment overrides with actual client connection.""" + # Get connection details from the fixture client + target_host = client.service_client.config.target_host + + toml_content = textwrap.dedent( + """ + [profile.staging] + address = "staging.temporal.com:443" + namespace = "staging-namespace" + + [profile.staging.grpc_meta] + "x-deployment" = "staging" + authorization = "Bearer staging-token" + """ + ) + + env_overrides = { + "TEMPORAL_ADDRESS": target_host, + "TEMPORAL_NAMESPACE": "override-namespace", + "TEMPORAL_GRPC_META_X_DEPLOYMENT": "canary", + "TEMPORAL_GRPC_META_AUTHORIZATION": "Bearer override-token", + } + + profile = ClientConfigProfile.load( + profile="staging", config_source=toml_content, override_env_vars=env_overrides + ) + + config = profile.to_client_connect_config() + + # Create client with environment overrides + new_client = await Client.connect(**config) + + # Verify environment overrides took effect + assert new_client.service_client.config.target_host == target_host + assert new_client.namespace == "override-namespace" + if new_client.service_client.config.rpc_metadata: + assert new_client.service_client.config.rpc_metadata["x-deployment"] == "canary" + assert ( + new_client.service_client.config.rpc_metadata["authorization"] + == "Bearer override-token" + ) + + +def test_tls_disabled_tri_state_behavior(): + """Test TLS disabled tri-state behavior: null (unset), false (enabled), true (disabled).""" + # Test 1: disabled=null (unset) with API key -> TLS enabled + toml_null = textwrap.dedent( + """ + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + server_name = "my-server" + """ + ) + profile_null = ClientConfigProfile.load(config_source=toml_null) + assert profile_null.tls is not None + assert profile_null.tls.disabled is None # disabled is null (unset) + config_null = profile_null.to_client_connect_config() + assert config_null.get("tls") is not None # TLS enabled + + # Test 2: disabled=false (explicitly enabled) -> TLS enabled + toml_false = textwrap.dedent( + """ + [profile.default] + address = "my-address" + [profile.default.tls] + disabled = false + server_name = "my-server" + """ + ) + profile_false = ClientConfigProfile.load(config_source=toml_false) + assert profile_false.tls is not None + assert profile_false.tls.disabled is False # explicitly disabled=false + config_false = profile_false.to_client_connect_config() + assert config_false.get("tls") is not None # TLS enabled + + # Test 3: disabled=true (explicitly disabled) -> TLS disabled even with API key + toml_true = textwrap.dedent( + """ + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + disabled = true + server_name = "should-be-ignored" + """ + ) + profile_true = ClientConfigProfile.load(config_source=toml_true) + assert profile_true.tls is not None + assert profile_true.tls.disabled is True # explicitly disabled=true + config_true = profile_true.to_client_connect_config() + assert config_true.get("tls") is False # TLS disabled even with API key + + +async def test_e2e_multi_profile_different_client_connections(client: Client): + """Test multiple profiles creating different client connections.""" + # Get connection details from the fixture client + target_host = client.service_client.config.target_host + + toml_content = textwrap.dedent( + f""" + [profile.development] + address = "{target_host}" + namespace = "dev" + + [profile.production] + address = "{target_host}" + namespace = "prod" + api_key = "prod-key" + + [profile.production.tls] + disabled = true + """ + ) + + # Load and create development client + dev_profile = ClientConfigProfile.load( + profile="development", config_source=toml_content + ) + + dev_config = dev_profile.to_client_connect_config() + dev_client = await Client.connect(**dev_config) + + # Load and create production client + prod_profile = ClientConfigProfile.load( + profile="production", config_source=toml_content + ) + + prod_config = prod_profile.to_client_connect_config() + prod_client = await Client.connect(**prod_config) + + # Verify different configurations for each client + assert dev_client.service_client.config.target_host == target_host + assert dev_client.namespace == "dev" + assert dev_client.service_client.config.api_key is None + assert dev_client.service_client.config.tls is False + + assert prod_client.service_client.config.target_host == target_host + assert prod_client.namespace == "prod" + assert prod_client.service_client.config.api_key == "prod-key"