@@ -17,7 +17,7 @@ def cleanup_test_files(self):
1717 """Clean up test files after all tests in the class complete."""
1818 yield
1919 # Clean up test files created during tests
20- test_files = ["test_data.json" , "test_message.txt" , "test_output.csv" , "test_page.html" ]
20+ test_files = ["test_data.json" , "test_message.txt" , "test_output.csv" , "test_page.html" , "test_s3_output.txt" ]
2121 for filename in test_files :
2222 filepath = Path (filename )
2323 if filepath .exists ():
@@ -109,7 +109,7 @@ async def test_save_dataframe_to_csv(self, component_class):
109109 mock_db = AsyncMock ()
110110 mock_session .return_value .__aenter__ .return_value = mock_db
111111 mock_get_user .return_value = MagicMock ()
112- mock_upload .return_value = "test_output.csv"
112+ mock_upload .return_value = MagicMock ( path = "test_output.csv" , provider = "s3" )
113113
114114 # Execute - real temp file creation, real DataFrame.to_csv(), real cleanup
115115 result = await component .save_to_file ()
@@ -143,7 +143,7 @@ async def test_save_data_to_json(self, component_class):
143143 mock_db = AsyncMock ()
144144 mock_session .return_value .__aenter__ .return_value = mock_db
145145 mock_get_user .return_value = MagicMock ()
146- mock_upload .return_value = "test_data.json"
146+ mock_upload .return_value = MagicMock ( path = "test_data.json" , provider = "s3" )
147147
148148 result = await component .save_to_file ()
149149
@@ -175,13 +175,172 @@ async def test_save_message_to_txt(self, component_class):
175175 mock_db = AsyncMock ()
176176 mock_session .return_value .__aenter__ .return_value = mock_db
177177 mock_get_user .return_value = MagicMock ()
178- mock_upload .return_value = "test_message.txt"
178+ mock_upload .return_value = MagicMock ( path = "test_message.txt" , provider = "s3" )
179179
180180 result = await component .save_to_file ()
181181
182182 assert "saved successfully" in result .text
183183 assert "test_message.txt" in result .text
184184
185+ @pytest .mark .asyncio
186+ async def test_save_local_mode_with_s3_backend_cleans_staging_and_reports_storage_path (
187+ self , component_class , tmp_path
188+ ):
189+ """Remote (S3) backend: Local mode deletes the staging file and reports the storage path.
190+
191+ This guards the fix for the leak where Local mode left a redundant copy in
192+ cwd and surfaced a misleading local path when the backend was S3.
193+ """
194+ component = component_class (_user_id = str (uuid4 ()))
195+ message = Message (text = "This should end up only in S3" )
196+ component .set_attributes (
197+ {
198+ "input" : message ,
199+ "file_name" : "test_s3_output" ,
200+ "local_format" : "txt" ,
201+ "storage_location" : [{"name" : "Local" }],
202+ }
203+ )
204+
205+ # upload_user_file returns the durable storage location + provider
206+ upload_response = MagicMock ()
207+ upload_response .path = "files/user-uuid/test_s3_output.txt"
208+ upload_response .provider = "s3"
209+
210+ # Force the storage backend to look remote (S3) without restricting paths
211+ settings_mock = MagicMock ()
212+ settings_mock .storage_type = "s3"
213+ settings_mock .restrict_local_file_access = False
214+ settings_mock .config_dir = str (tmp_path )
215+ settings_service_mock = MagicMock ()
216+ settings_service_mock .settings = settings_mock
217+
218+ with (
219+ patch ("langflow.api.v2.files.upload_user_file" , new_callable = AsyncMock ) as mock_upload ,
220+ patch ("lfx.services.deps.session_scope" ) as mock_session ,
221+ patch (
222+ "langflow.services.database.models.user.crud.get_user_by_id" , new_callable = AsyncMock
223+ ) as mock_get_user ,
224+ patch (
225+ "lfx.components.files_and_knowledge.save_file.get_settings_service" ,
226+ return_value = settings_service_mock ,
227+ ),
228+ ):
229+ mock_db = AsyncMock ()
230+ mock_session .return_value .__aenter__ .return_value = mock_db
231+ mock_get_user .return_value = MagicMock ()
232+ mock_upload .return_value = upload_response
233+
234+ result = await component .save_to_file ()
235+
236+ # Message reports the durable storage destination + provider, not a local path
237+ assert "files/user-uuid/test_s3_output.txt" in result .text
238+ assert "S3" in result .text
239+ assert str (tmp_path ) not in result .text
240+ # The local staging file was cleaned up
241+ assert not (Path .cwd () / "test_s3_output.txt" ).exists ()
242+
243+ @pytest .mark .asyncio
244+ async def test_append_mode_remote_backend_accumulates_across_calls (self , component_class , tmp_path ):
245+ """append_mode + remote (S3) backend keeps accumulating, not resetting to overwrite.
246+
247+ Guards against the staging cleanup deleting the local accumulator that append
248+ relies on (should_append hinges on the file persisting across calls). Without
249+ the append exception, the second call would silently overwrite.
250+ """
251+ file_name = "test_append_remote"
252+ staging = Path .cwd () / f"{ file_name } .txt"
253+ if staging .exists ():
254+ staging .unlink ()
255+
256+ settings_mock = MagicMock ()
257+ settings_mock .storage_type = "s3"
258+ settings_mock .restrict_local_file_access = False
259+ settings_mock .config_dir = str (tmp_path )
260+ settings_service_mock = MagicMock ()
261+ settings_service_mock .settings = settings_mock
262+
263+ upload_response = MagicMock ()
264+ upload_response .path = f"files/uid/{ file_name } .txt"
265+ upload_response .provider = "s3"
266+
267+ def make_component (text ):
268+ component = component_class (_user_id = str (uuid4 ()))
269+ component .set_attributes (
270+ {
271+ "input" : Message (text = text ),
272+ "file_name" : file_name ,
273+ "local_format" : "txt" ,
274+ "append_mode" : True ,
275+ "storage_location" : [{"name" : "Local" }],
276+ }
277+ )
278+ return component
279+
280+ try :
281+ with (
282+ patch ("langflow.api.v2.files.upload_user_file" , new_callable = AsyncMock ) as mock_upload ,
283+ patch ("lfx.services.deps.session_scope" ) as mock_session ,
284+ patch (
285+ "langflow.services.database.models.user.crud.get_user_by_id" , new_callable = AsyncMock
286+ ) as mock_get_user ,
287+ patch (
288+ "lfx.components.files_and_knowledge.save_file.get_settings_service" ,
289+ return_value = settings_service_mock ,
290+ ),
291+ ):
292+ mock_db = AsyncMock ()
293+ mock_session .return_value .__aenter__ .return_value = mock_db
294+ mock_get_user .return_value = MagicMock ()
295+ mock_upload .return_value = upload_response
296+
297+ await make_component ("line one" ).save_to_file ()
298+ # Staging file must survive so the next call can append to it
299+ assert staging .exists ()
300+ await make_component ("line two" ).save_to_file ()
301+
302+ # Content accumulated across both calls — not overwritten
303+ assert staging .read_text (encoding = "utf-8" ) == "line one\n line two"
304+ finally :
305+ if staging .exists ():
306+ staging .unlink ()
307+
308+ @pytest .mark .asyncio
309+ async def test_save_aws_mode_namespaces_key_by_user_id (self , component_class ):
310+ """AWS mode namespaces the S3 key by user_id so multi-user runs don't collide.
311+
312+ Layout must be {s3_prefix}/{user_id}/{file_name}.{ext} — otherwise every
313+ user writing the same file_name overwrites the same key.
314+ """
315+ user_id = str (uuid4 ())
316+ component = component_class (_user_id = user_id )
317+ component .set_attributes (
318+ {
319+ "input" : Message (text = "hello aws" ),
320+ "file_name" : "report" ,
321+ "aws_format" : "txt" ,
322+ "storage_location" : [{"name" : "AWS" }],
323+ "aws_access_key_id" : "test-access-key" , # pragma: allowlist secret
324+ "aws_secret_access_key" : "test-secret-key" , # pragma: allowlist secret
325+ "bucket_name" : "my-bucket" ,
326+ "aws_region" : "us-east-1" ,
327+ "s3_prefix" : "files" ,
328+ }
329+ )
330+
331+ mock_s3 = MagicMock ()
332+ with (
333+ patch ("boto3.client" , return_value = mock_s3 ),
334+ patch ("lfx.base.data.cloud_storage_utils.validate_aws_credentials" ),
335+ ):
336+ result = await component .save_to_file ()
337+
338+ # upload_file(temp_path, bucket, key) — third positional arg is the S3 key
339+ mock_s3 .upload_file .assert_called_once ()
340+ key = mock_s3 .upload_file .call_args [0 ][2 ]
341+ assert key == f"files/{ user_id } /report.txt"
342+ assert "my-bucket" in result .text
343+
185344 @pytest .mark .asyncio
186345 async def test_save_message_to_html (self , component_class ):
187346 """Test saving Message to html format."""
@@ -207,7 +366,7 @@ async def test_save_message_to_html(self, component_class):
207366 mock_db = AsyncMock ()
208367 mock_session .return_value .__aenter__ .return_value = mock_db
209368 mock_get_user .return_value = MagicMock ()
210- mock_upload .return_value = "test_page.html"
369+ mock_upload .return_value = MagicMock ( path = "test_page.html" , provider = "s3" )
211370
212371 result = await component .save_to_file ()
213372
@@ -319,7 +478,7 @@ async def test_file_name_with_extension_stripped(self, component_class):
319478 mock_db = AsyncMock ()
320479 mock_session .return_value .__aenter__ .return_value = mock_db
321480 mock_get_user .return_value = MagicMock ()
322- mock_upload .return_value = "test_output.csv"
481+ mock_upload .return_value = MagicMock ( path = "test_output.csv" , provider = "s3" )
323482
324483 result = await component .save_to_file ()
325484
@@ -349,6 +508,16 @@ async def test_append_mode_txt_file(self, component_class):
349508 }
350509 )
351510
511+ # This test verifies LOCAL-backend append semantics (the file persists on
512+ # disk and is re-read), so pin the backend to local. Under a remote (S3)
513+ # backend the staging file is intentionally deleted after upload.
514+ settings_mock = MagicMock ()
515+ settings_mock .storage_type = "local"
516+ settings_mock .restrict_local_file_access = False
517+ settings_mock .config_dir = str (tmp_path .parent )
518+ settings_service_mock = MagicMock ()
519+ settings_service_mock .settings = settings_mock
520+
352521 # Mock the path resolution to return our temp file
353522 with (
354523 patch ("lfx.components.files_and_knowledge.save_file.Path" ) as mock_path_class ,
@@ -357,13 +526,17 @@ async def test_append_mode_txt_file(self, component_class):
357526 patch (
358527 "langflow.services.database.models.user.crud.get_user_by_id" , new_callable = AsyncMock
359528 ) as mock_get_user ,
529+ patch (
530+ "lfx.components.files_and_knowledge.save_file.get_settings_service" ,
531+ return_value = settings_service_mock ,
532+ ),
360533 ):
361534 # Make Path() return our temp file path
362535 mock_path_class .return_value = tmp_path
363536 mock_db = AsyncMock ()
364537 mock_session .return_value .__aenter__ .return_value = mock_db
365538 mock_get_user .return_value = MagicMock ()
366- mock_upload .return_value = tmp_path .name
539+ mock_upload .return_value = MagicMock ( path = tmp_path .name , provider = "local" )
367540
368541 result = await component .save_to_file ()
369542
0 commit comments