1414from contextlib import asynccontextmanager
1515from types import SimpleNamespace
1616from typing import Any
17+ from unittest .mock import AsyncMock , MagicMock
1718from uuid import UUID , uuid4
1819
1920import pytest
@@ -62,7 +63,12 @@ def patch_build_flow(monkeypatch):
6263 """Install fakes for session_scope, _read_flow, ensure_flow_permission, start_flow_build."""
6364 from langflow .api .v1 import chat as chat_module
6465
65- state : dict [str , Any ] = {"session_exec" : [], "read_flow" : None , "ensure_raises" : None }
66+ state : dict [str , Any ] = {
67+ "session_exec" : [],
68+ "read_flow" : None ,
69+ "ensure_raises" : None ,
70+ "start_kwargs" : None ,
71+ }
6672
6773 @asynccontextmanager
6874 async def fake_session_scope ():
@@ -75,7 +81,8 @@ async def fake_ensure(*_args, **_kwargs):
7581 if state ["ensure_raises" ] is not None :
7682 raise state ["ensure_raises" ]
7783
78- async def fake_start_build (** _kwargs ):
84+ async def fake_start_build (** kwargs ):
85+ state ["start_kwargs" ] = kwargs
7986 return "fake-job-id"
8087
8188 monkeypatch .setattr (chat_module , "session_scope" , fake_session_scope )
@@ -171,7 +178,10 @@ async def test_build_flow_owner_can_override_flow_data(patch_build_flow, monkeyp
171178 from langflow .api .v1 import chat as chat_module
172179 from langflow .api .v1 .schemas import FlowDataRequest
173180
174- monkeypatch .setattr (chat_module , "validate_flow_for_current_settings" , lambda _data : None )
181+ async def allow_inline (_data , * , is_superuser ):
182+ assert is_superuser is False
183+
184+ monkeypatch .setattr (chat_module , "prepare_flow_build_for_user" , allow_inline )
175185
176186 owner = _make_user ()
177187 flow = _make_flow (owner_id = owner .id , public = False )
@@ -188,6 +198,152 @@ async def test_build_flow_owner_can_override_flow_data(patch_build_flow, monkeyp
188198 assert result == {"job_id" : "fake-job-id" }
189199
190200
201+ @pytest .mark .asyncio
202+ async def test_build_flow_admin_only_blocks_non_superuser_inline_custom_code (patch_build_flow , monkeypatch ):
203+ """Admin-only policy is applied before owner-supplied inline graph data reaches the build worker."""
204+ from langflow .api .v1 import chat as chat_module
205+ from langflow .api .v1 .schemas import FlowDataRequest
206+ from lfx .utils .flow_validation import CustomComponentValidationError
207+
208+ owner = _make_user ()
209+ flow = _make_flow (owner_id = owner .id , public = False )
210+ patch_build_flow ["read_flow" ] = flow
211+ override = FlowDataRequest (nodes = [{"id" : "custom" }], edges = [])
212+
213+ async def reject_custom_code (_data , * , is_superuser ):
214+ assert is_superuser is False
215+ message = "custom components are restricted to administrators"
216+ raise CustomComponentValidationError (message )
217+
218+ monkeypatch .setattr (chat_module , "prepare_flow_build_for_user" , reject_custom_code )
219+
220+ with pytest .raises (HTTPException ) as excinfo :
221+ await chat_module .build_flow (
222+ flow_id = flow .id ,
223+ background_tasks = None ,
224+ current_user = owner ,
225+ queue_service = _make_queue_service (),
226+ data = override ,
227+ )
228+
229+ assert excinfo .value .status_code == 400
230+ assert "restricted to administrators" in excinfo .value .detail
231+ assert patch_build_flow ["start_kwargs" ] is None
232+
233+
234+ @pytest .mark .asyncio
235+ async def test_build_flow_admin_only_passes_sanitized_template_to_worker (patch_build_flow , monkeypatch ):
236+ """A known template remains usable, but the worker receives the server-trusted payload."""
237+ from langflow .api .v1 import chat as chat_module
238+ from langflow .api .v1 .schemas import FlowDataRequest
239+
240+ owner = _make_user ()
241+ flow = _make_flow (owner_id = owner .id , public = False )
242+ patch_build_flow ["read_flow" ] = flow
243+ override = FlowDataRequest (nodes = [{"id" : "known" , "data" : {"source" : "request" }}], edges = [])
244+ sanitized = {"nodes" : [{"id" : "known" , "data" : {"source" : "server" }}], "edges" : [], "viewport" : None }
245+
246+ async def sanitize (_data , * , is_superuser ):
247+ assert is_superuser is False
248+ return sanitized
249+
250+ monkeypatch .setattr (chat_module , "prepare_flow_build_for_user" , sanitize )
251+
252+ result = await chat_module .build_flow (
253+ flow_id = flow .id ,
254+ background_tasks = None ,
255+ current_user = owner ,
256+ queue_service = _make_queue_service (),
257+ data = override ,
258+ )
259+
260+ assert result == {"job_id" : "fake-job-id" }
261+ assert patch_build_flow ["start_kwargs" ]["data" ].model_dump () == sanitized
262+ assert override .nodes [0 ]["data" ]["source" ] == "request"
263+
264+
265+ @pytest .mark .asyncio
266+ async def test_build_flow_admin_only_keeps_superuser_inline_custom_code (patch_build_flow , monkeypatch ):
267+ """The operator's admin-only setting preserves the documented superuser exception."""
268+ from langflow .api .v1 import chat as chat_module
269+ from langflow .api .v1 .schemas import FlowDataRequest
270+
271+ owner = _make_user (is_superuser = True )
272+ flow = _make_flow (owner_id = owner .id , public = False )
273+ patch_build_flow ["read_flow" ] = flow
274+ override = FlowDataRequest (nodes = [{"id" : "custom" }], edges = [])
275+ seen : dict [str , Any ] = {}
276+
277+ async def preserve_superuser_data (data , * , is_superuser ):
278+ assert is_superuser is True
279+ seen ["validated" ] = data
280+
281+ monkeypatch .setattr (chat_module , "prepare_flow_build_for_user" , preserve_superuser_data )
282+
283+ result = await chat_module .build_flow (
284+ flow_id = flow .id ,
285+ background_tasks = None ,
286+ current_user = owner ,
287+ queue_service = _make_queue_service (),
288+ data = override ,
289+ )
290+
291+ assert result == {"job_id" : "fake-job-id" }
292+ assert seen ["validated" ] == override .model_dump ()
293+ assert patch_build_flow ["start_kwargs" ]["data" ] is override
294+
295+
296+ @pytest .mark .asyncio
297+ async def test_legacy_vertices_route_passes_only_sanitized_inline_data (monkeypatch ):
298+ """The still-routed legacy graph cache cannot bypass the inline-data policy."""
299+ from langflow .api .v1 import chat as chat_module
300+ from langflow .api .v1 .schemas import FlowDataRequest
301+
302+ owner = _make_user ()
303+ flow = _make_flow (owner_id = owner .id )
304+ original = FlowDataRequest (nodes = [{"id" : "known" , "data" : {"source" : "request" }}], edges = [])
305+ sanitized = {"nodes" : [{"id" : "known" , "data" : {"source" : "server" }}], "edges" : [], "viewport" : None }
306+ seen : dict [str , Any ] = {}
307+
308+ async def ensure_allowed (* _args , ** _kwargs ):
309+ return None
310+
311+ async def sanitize (_data , * , is_superuser ):
312+ assert is_superuser is False
313+ return sanitized
314+
315+ graph = MagicMock ()
316+ graph .prepare .return_value = graph
317+ graph .vertices = []
318+ graph .vertices_to_run = set ()
319+ graph .first_layer = []
320+ graph .run_id = None
321+ graph .set_run_id .side_effect = lambda value : setattr (graph , "run_id" , value )
322+
323+ async def build_from_data (* , graph_data , ** _kwargs ):
324+ seen ["graph_data" ] = graph_data
325+ return graph
326+
327+ chat_service = SimpleNamespace (set_cache = AsyncMock ())
328+ monkeypatch .setattr (chat_module , "ensure_flow_permission" , ensure_allowed )
329+ monkeypatch .setattr (chat_module , "prepare_flow_build_for_user" , sanitize )
330+ monkeypatch .setattr (chat_module , "build_and_cache_graph_from_data" , build_from_data )
331+ monkeypatch .setattr (chat_module , "get_chat_service" , lambda : chat_service )
332+ monkeypatch .setattr (chat_module , "get_telemetry_service" , MagicMock )
333+ monkeypatch .setattr (chat_module , "get_top_level_vertices" , lambda * _args : [])
334+
335+ await chat_module .retrieve_vertices_order (
336+ flow_id = flow .id ,
337+ background_tasks = SimpleNamespace (add_task = lambda * _args , ** _kwargs : None ),
338+ data = original ,
339+ session = _FakeSession ([[flow ]]),
340+ current_user = owner ,
341+ )
342+
343+ assert seen ["graph_data" ] == sanitized
344+ assert original .nodes [0 ]["data" ]["source" ] == "request"
345+
346+
191347@pytest .mark .asyncio
192348async def test_build_flow_plugin_deny_returns_404_not_403 (patch_build_flow ):
193349 """ensure_flow_permission raising 403 must surface as 404 (UUID privacy)."""
0 commit comments