@@ -51,6 +51,10 @@ async def _instantiate_component(self, component_info: dict[str, Any]) -> tuple[
5151 return component_info .get ("instance" ), component_info .get ("name" , "test" )
5252
5353
54+ # Pydantic renders a non-empty SecretStr field as this mask under model_dump(mode="json").
55+ MASKED_SECRET = "**********" # pragma: allowlist secret
56+
57+
5458class TestBaseModelOutputHandler :
5559 """Test cases for BaseModelOutputHandler serialization."""
5660
@@ -84,8 +88,8 @@ async def test_simple_basemodel_serialization(self):
8488 assert serialized ["__module_name__" ] == "tests.unit.test_type_converter"
8589
8690 @pytest .mark .asyncio
87- async def test_secret_str_serialization_with_actual_secret (self ):
88- """Test that SecretStr fields are properly serialized with actual values ."""
91+ async def test_secret_str_fields_are_masked (self ):
92+ """SecretStr fields serialize masked -- the plaintext value is never emitted ."""
8993 secret_value = "test-api-key-12345" # pragma: allowlist secret
9094 model = SecretTestModel (
9195 name = "test" ,
@@ -95,49 +99,32 @@ async def test_secret_str_serialization_with_actual_secret(self):
9599
96100 serialized = await self .handler .process (model )
97101
98- # Check that secret values are properly extracted
99102 assert serialized ["name" ] == "test"
100- assert serialized ["api_key" ] == secret_value # pragma: allowlist secret
101- assert serialized ["optional_secret" ] == "optional-secret-value" # pragma: allowlist secret
103+ assert serialized ["api_key" ] == MASKED_SECRET
104+ assert serialized ["optional_secret" ] == MASKED_SECRET
105+ assert secret_value not in serialized .values ()
102106
103- # Check class metadata
104107 assert serialized ["__class_name__" ] == "SecretTestModel"
105108 assert serialized ["__module_name__" ] == "tests.unit.test_type_converter"
106109
107110 @pytest .mark .asyncio
108- async def test_secret_str_serialization_with_env_var_resolution (self ):
109- """Test that SecretStr fields with environment variable names are resolved."""
110- # Set up environment variable
111+ async def test_env_var_name_secret_is_not_resolved (self ):
112+ """A SecretStr holding an env var name must NOT be resolved onto the output edge."""
111113 test_api_key = "actual-api-key-from-env" # pragma: allowlist secret
112114
113115 with patch .dict (os .environ , {"TEST_API_KEY" : test_api_key }):
114- # Create model with environment variable name as secret
115- model = SecretTestModel (
116- name = "test" ,
117- api_key = SecretStr ("TEST_API_KEY" ), # Environment variable name
118- )
116+ model = SecretTestModel (name = "test" , api_key = SecretStr ("TEST_API_KEY" ))
119117
120118 serialized = await self .handler .process (model )
121119
122- # Check that environment variable was resolved
123- assert serialized ["api_key" ] == test_api_key
120+ # Neither the env var value nor its name should leak; the field stays masked.
121+ assert serialized ["api_key" ] == MASKED_SECRET
122+ assert test_api_key not in serialized .values ()
124123 assert serialized ["name" ] == "test"
125124
126125 @pytest .mark .asyncio
127- async def test_secret_str_serialization_no_env_var_fallback (self ):
128- """Test SecretStr serialization when environment variable doesn't exist."""
129- # Create model with non-existent environment variable name
130- model = SecretTestModel (name = "test" , api_key = SecretStr ("NON_EXISTENT_ENV_VAR" ))
131-
132- serialized = await self .handler .process (model )
133-
134- # Should keep the original value if env var doesn't exist
135- assert serialized ["api_key" ] == "NON_EXISTENT_ENV_VAR" # pragma: allowlist secret
136- assert serialized ["name" ] == "test"
137-
138- @pytest .mark .asyncio
139- async def test_openai_embeddings_like_serialization (self ):
140- """Test serialization of OpenAI-like embeddings model."""
126+ async def test_openai_embeddings_like_serialization_masks_key (self ):
127+ """OpenAI-like embeddings model serializes with its api key masked, others intact."""
141128 api_key = "real-openai-key-12345" # pragma: allowlist secret
142129 model = MockOpenAIEmbeddings (
143130 model = "text-embedding-3-small" ,
@@ -148,33 +135,19 @@ async def test_openai_embeddings_like_serialization(self):
148135
149136 serialized = await self .handler .process (model )
150137
151- # Check all fields are properly serialized
152138 assert serialized ["model" ] == "text-embedding-3-small"
153- assert serialized ["openai_api_key" ] == api_key # SecretStr unwrapped
139+ assert serialized ["openai_api_key" ] == MASKED_SECRET
140+ assert api_key not in serialized .values ()
154141 assert serialized ["chunk_size" ] == 500
155142 assert serialized ["max_retries" ] == 3 # Default value
156143 assert serialized ["dimensions" ] == 1536
157144
158- # Check class metadata for reconstruction
159145 assert serialized ["__class_name__" ] == "MockOpenAIEmbeddings"
160146 assert serialized ["__module_name__" ] == "tests.unit.test_type_converter"
161147
162- @pytest .mark .asyncio
163- async def test_openai_api_key_env_var_resolution (self ):
164- """Test that OPENAI_API_KEY environment variable is properly resolved."""
165- real_api_key = "proj-real-openai-key-from-environment" # pragma: allowlist secret
166-
167- with patch .dict (os .environ , {"OPENAI_API_KEY" : real_api_key }):
168- model = MockOpenAIEmbeddings (openai_api_key = SecretStr ("OPENAI_API_KEY" ))
169-
170- serialized = await self .handler .process (model )
171-
172- # Verify the actual API key value is serialized, not the env var name
173- assert serialized ["openai_api_key" ] == real_api_key
174-
175148 @pytest .mark .asyncio
176149 async def test_mixed_secret_and_regular_fields (self ):
177- """Test model with both secret and regular fields."""
150+ """Model with both secret and regular fields: secret masked, regular fields intact ."""
178151 api_key = "secret-key-value" # pragma: allowlist secret
179152 model = SecretTestModel (
180153 name = "production-model" ,
@@ -184,38 +157,12 @@ async def test_mixed_secret_and_regular_fields(self):
184157
185158 serialized = await self .handler .process (model )
186159
187- # Regular field should be unchanged
188160 assert serialized ["name" ] == "production-model"
189- # Secret field should be unwrapped
190- assert serialized [ " api_key" ] == api_key
161+ assert serialized [ "api_key" ] == MASKED_SECRET
162+ assert api_key not in serialized . values ()
191163 # None secret field should remain None
192164 assert serialized ["optional_secret" ] is None
193165
194- @pytest .mark .asyncio
195- @patch .dict (os .environ , {}, clear = True ) # Clear environment
196- async def test_secret_str_with_no_env_vars (self ):
197- """Test SecretStr serialization when no environment variables are set."""
198- model = SecretTestModel (name = "test" , api_key = SecretStr ("MISSING_API_KEY" ))
199-
200- serialized = await self .handler .process (model )
201-
202- # Should keep original value if env var resolution fails
203- assert serialized ["api_key" ] == "MISSING_API_KEY" # pragma: allowlist secret
204-
205- @pytest .mark .asyncio
206- async def test_serialized_data_structure_debugging (self ):
207- """Test to show what serialized data looks like for debugging."""
208- api_key = "debug-key-12345" # pragma: allowlist secret
209- model = MockOpenAIEmbeddings (model = "test-model" , openai_api_key = SecretStr (api_key ), chunk_size = 123 )
210-
211- serialized = await self .handler .process (model )
212-
213- # Verify the structure
214- assert "openai_api_key" in serialized
215- assert serialized ["openai_api_key" ] == api_key
216- assert "__class_name__" in serialized
217- assert "__module_name__" in serialized
218-
219166
220167class TestBaseModelInputHandler :
221168 """Test cases for BaseModelInputHandler deserialization."""
@@ -241,8 +188,13 @@ async def test_simple_basemodel_deserialization(self):
241188 assert deserialized .enabled is False
242189
243190 @pytest .mark .asyncio
244- async def test_openai_embeddings_like_deserialization (self ):
245- """Test deserialization of OpenAI-like embeddings model."""
191+ async def test_secret_round_trip_stays_masked (self ):
192+ """Across the worker boundary a secret survives only as its mask, not the real value.
193+
194+ Masking on the output edge is intentional (see BaseModelOutputHandler): the plaintext
195+ is never serialized, so a serialize->deserialize round trip reconstructs the masked
196+ SecretStr. A component needing the real value must resolve it on its own input edge.
197+ """
246198 api_key = "real-openai-key-12345" # pragma: allowlist secret
247199 model = MockOpenAIEmbeddings (
248200 model = "text-embedding-ada-002" ,
@@ -261,27 +213,10 @@ async def test_openai_embeddings_like_deserialization(self):
261213 assert deserialized .chunk_size == 750
262214 assert deserialized .max_retries == 3 # Default
263215
264- # Check that SecretStr field is reconstructed properly
216+ # The real key never crossed the boundary; only the mask round-trips.
265217 assert isinstance (deserialized .openai_api_key , SecretStr )
266- assert deserialized .openai_api_key .get_secret_value () == api_key
267-
268- @pytest .mark .asyncio
269- async def test_openai_api_key_env_var_round_trip (self ):
270- """Test that OPENAI_API_KEY environment variable is properly resolved."""
271- real_api_key = "proj-real-openai-key-from-environment" # pragma: allowlist secret
272-
273- with patch .dict (os .environ , {"OPENAI_API_KEY" : real_api_key }):
274- model = MockOpenAIEmbeddings (openai_api_key = SecretStr ("OPENAI_API_KEY" ))
275-
276- serialized = await self .output_handler .process (model )
277-
278- # Test full round-trip
279- fields = {"param" : (serialized , {})}
280- result = await self .input_handler .prepare (fields , None )
281-
282- deserialized = result ["param" ]
283- assert isinstance (deserialized .openai_api_key , SecretStr )
284- assert deserialized .openai_api_key .get_secret_value () == real_api_key
218+ assert deserialized .openai_api_key .get_secret_value () == MASKED_SECRET
219+ assert deserialized .openai_api_key .get_secret_value () != api_key
285220
286221 def test_deserialization_without_class_metadata (self ):
287222 """Test that regular dicts without markers don't match."""
@@ -377,8 +312,8 @@ async def test_langflow_types_still_work(self):
377312 pytest .skip ("Langflow not available for testing" )
378313
379314 @pytest .mark .asyncio
380- async def test_real_openai_embeddings_serialization (self ):
381- """Test with actual OpenAI embeddings class if available ."""
315+ async def test_real_openai_embeddings_serialization_masks_key (self ):
316+ """Real OpenAIEmbeddings serializes with its api key masked, not unwrapped ."""
382317 try :
383318 from langchain_openai import OpenAIEmbeddings
384319 from pydantic import SecretStr
@@ -394,17 +329,18 @@ async def test_real_openai_embeddings_serialization(self):
394329 # Serialize
395330 serialized = await self .executor ._apply_output_handlers (embeddings , self .handlers )
396331
397- # Check that API key is properly extracted
332+ # The plaintext key must never reach the serialized output edge.
398333 assert "openai_api_key" in serialized
399- assert serialized ["openai_api_key" ] == api_key
334+ assert serialized ["openai_api_key" ] == MASKED_SECRET
335+ assert api_key not in serialized .values ()
400336 assert serialized ["model" ] == "text-embedding-3-small"
401337 assert serialized ["chunk_size" ] == 500
402338
403339 # Check metadata for reconstruction
404340 assert serialized ["__class_name__" ] == "OpenAIEmbeddings"
405341 assert "langchain_openai" in serialized ["__module_name__" ]
406342
407- # Test deserialization
343+ # Round-trips as the mask, never the real key.
408344 input_handler = BaseModelInputHandler ()
409345 fields = {"param" : (serialized , {})}
410346 result = await input_handler .prepare (fields , None )
@@ -414,13 +350,12 @@ async def test_real_openai_embeddings_serialization(self):
414350 assert deserialized .model == "text-embedding-3-small"
415351 assert deserialized .chunk_size == 500
416352
417- # Check that SecretStr is properly reconstructed
418353 assert hasattr (deserialized , "openai_api_key" )
419354 if isinstance (deserialized .openai_api_key , SecretStr ):
420- assert deserialized .openai_api_key .get_secret_value () == api_key
355+ assert deserialized .openai_api_key .get_secret_value () == MASKED_SECRET
356+ assert deserialized .openai_api_key .get_secret_value () != api_key
421357 else :
422- # In some versions, it might be a string after deserialization
423- assert deserialized .openai_api_key == api_key
358+ assert deserialized .openai_api_key == MASKED_SECRET
424359
425360 except ImportError :
426361 pytest .skip ("langchain_openai not available for real OpenAI embeddings test" )
0 commit comments