diff --git a/src/backend/base/langflow/api/utils/zip_utils.py b/src/backend/base/langflow/api/utils/zip_utils.py new file mode 100644 index 0000000000..57358a3811 --- /dev/null +++ b/src/backend/base/langflow/api/utils/zip_utils.py @@ -0,0 +1,85 @@ +"""Utilities for handling ZIP file uploads containing flow JSON data.""" + +from __future__ import annotations + +import asyncio +import io +import zipfile +from dataclasses import dataclass, field + +import orjson +from lfx.log.logger import logger + +# Safety limits to prevent zip bomb / DoS attacks +MAX_ZIP_ENTRIES = 500 +MAX_ENTRY_UNCOMPRESSED_BYTES = 50 * 1024 * 1024 # 50 MB per file + + +@dataclass +class _ZipExtractionResult: + """Result of synchronous ZIP extraction, including warnings to log after.""" + + flows: list[dict] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +def _extract_flows_sync(contents: bytes) -> _ZipExtractionResult: + """Synchronous helper that performs all blocking ZIP I/O. + + Raises: + ValueError: If the ZIP is corrupt or contains more than MAX_ZIP_ENTRIES JSON files. + """ + result = _ZipExtractionResult() + + try: + zf = zipfile.ZipFile(io.BytesIO(contents), "r") + except zipfile.BadZipFile as exc: + msg = f"Uploaded file is not a valid ZIP archive: {exc}" + raise ValueError(msg) from exc + + with zf: + json_entries = [info for info in zf.infolist() if info.filename.lower().endswith(".json")] + + if len(json_entries) > MAX_ZIP_ENTRIES: + msg = f"ZIP contains {len(json_entries)} JSON entries, exceeding the limit of {MAX_ZIP_ENTRIES}" + raise ValueError(msg) + + for info in json_entries: + if info.file_size > MAX_ENTRY_UNCOMPRESSED_BYTES: + result.warnings.append( + f"Skipping ZIP entry '{info.filename}': uncompressed size " + f"{info.file_size} exceeds limit of {MAX_ENTRY_UNCOMPRESSED_BYTES} bytes" + ) + continue + try: + raw = zf.read(info.filename) + if len(raw) > MAX_ENTRY_UNCOMPRESSED_BYTES: + result.warnings.append( + f"Skipping ZIP entry '{info.filename}': actual size " + f"{len(raw)} exceeds limit of {MAX_ENTRY_UNCOMPRESSED_BYTES} bytes" + ) + continue + result.flows.append(orjson.loads(raw)) + except orjson.JSONDecodeError: + result.warnings.append(f"Skipping ZIP entry '{info.filename}': invalid JSON") + continue + + return result + + +async def extract_flows_from_zip(contents: bytes) -> list[dict]: + """Extract flow JSON data from a ZIP file. + + Reads all .json files from the ZIP archive and returns their parsed contents. + Enforces limits on entry count and individual file size to mitigate zip bomb attacks. + Blocking I/O is offloaded to a thread to avoid blocking the event loop. + + Raises: + ValueError: If the ZIP is corrupt or contains more than MAX_ZIP_ENTRIES JSON files. + """ + result = await asyncio.to_thread(_extract_flows_sync, contents) + + for warning in result.warnings: + await logger.awarning(warning) + + return result.flows diff --git a/src/backend/base/langflow/api/v1/flows.py b/src/backend/base/langflow/api/v1/flows.py index 0d308ada72..2eeaf58ede 100644 --- a/src/backend/base/langflow/api/v1/flows.py +++ b/src/backend/base/langflow/api/v1/flows.py @@ -22,6 +22,7 @@ from sqlmodel import and_, col, select from sqlmodel.ext.asyncio.session import AsyncSession from langflow.api.utils import CurrentActiveUser, DbSession, cascade_delete_flow, remove_api_keys, validate_is_component +from langflow.api.utils.zip_utils import extract_flows_from_zip from langflow.api.v1.schemas import FlowListCreate from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name from langflow.initial_setup.constants import STARTER_FOLDER_NAME @@ -739,14 +740,37 @@ async def create_flows( async def upload_file( *, session: DbSession, - file: Annotated[UploadFile, File(...)], + file: Annotated[UploadFile | None, File()] = None, current_user: CurrentActiveUser, folder_id: UUID | None = None, storage_service: Annotated[StorageService, Depends(get_storage_service)], ): - """Upload flows from a file.""" + """Upload flows from a file. + + Accepts either a JSON file (single flow or multi-flow format) or a ZIP file + containing individual flow JSON files (as produced by the download endpoint). + """ + if file is None: + raise HTTPException(status_code=400, detail="No file provided") + contents = await file.read() - data = orjson.loads(contents) + + if not contents: + raise HTTPException(status_code=400, detail="The uploaded file is empty") + + if zipfile.is_zipfile(io.BytesIO(contents)): + try: + flows_data = await extract_flows_from_zip(contents) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + if not flows_data: + raise HTTPException(status_code=400, detail="No valid flow JSON files found in the ZIP") + data = {"flows": flows_data} + else: + try: + data = orjson.loads(contents) + except orjson.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}") from e flow_list = FlowListCreate(**data) if "flows" in data else FlowListCreate(flows=[FlowCreate(**data)]) diff --git a/src/backend/base/langflow/api/v1/projects.py b/src/backend/base/langflow/api/v1/projects.py index f987434f17..7777890e66 100644 --- a/src/backend/base/langflow/api/v1/projects.py +++ b/src/backend/base/langflow/api/v1/projects.py @@ -20,6 +20,7 @@ from sqlmodel import select from langflow.api.utils import CurrentActiveUser, DbSession, cascade_delete_flow, custom_params, remove_api_keys from langflow.api.utils.mcp.config_utils import validate_mcp_server_for_project +from langflow.api.utils.zip_utils import extract_flows_from_zip from langflow.api.v1.auth_helpers import handle_auth_settings_update from langflow.api.v1.flows import create_flows from langflow.api.v1.mcp_projects import ( @@ -640,12 +641,44 @@ async def download_file( async def upload_file( *, session: DbSession, - file: Annotated[UploadFile, File(...)], + file: Annotated[UploadFile | None, File()] = None, current_user: CurrentActiveUser, ): - """Upload flows from a file.""" + """Upload flows from a file. + + Accepts either a JSON file with project metadata (folder_name, folder_description, flows) + or a ZIP file containing individual flow JSON files (as produced by the download endpoint). + """ + if file is None: + raise HTTPException(status_code=400, detail="No file provided") + contents = await file.read() - data = orjson.loads(contents) + + if not contents: + raise HTTPException(status_code=400, detail="The uploaded file is empty") + + # Detect ZIP files and extract flow data + if zipfile.is_zipfile(io.BytesIO(contents)): + try: + flows_data = await extract_flows_from_zip(contents) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + if not flows_data: + raise HTTPException(status_code=400, detail="No valid flow JSON files found in the ZIP") + + # Use the uploaded filename (without extension) as the project name + project_name_base = file.filename.rsplit(".", 1)[0] if file.filename else "Imported Project" + project_name_base = project_name_base or "Imported Project" + data: dict = { + "folder_name": project_name_base, + "folder_description": "", + "flows": flows_data, + } + else: + try: + data = orjson.loads(contents) + except orjson.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}") from e if not data: raise HTTPException(status_code=400, detail="No flows found in the file") @@ -654,7 +687,7 @@ async def upload_file( data["folder_name"] = project_name - project = FolderCreate(name=data["folder_name"], description=data["folder_description"]) + project = FolderCreate(name=data["folder_name"], description=data.get("folder_description", "")) new_project = Folder.model_validate(project, from_attributes=True) new_project.id = None @@ -675,15 +708,26 @@ async def upload_file( await session.flush() await session.refresh(new_project) del data["folder_name"] - del data["folder_description"] + data.pop("folder_description", None) if "flows" in data: flow_list = FlowListCreate(flows=[FlowCreate(**flow) for flow in data["flows"]]) else: raise HTTPException(status_code=400, detail="No flows found in the data") - # Now we set the user_id for all flows + # Generate unique names, tracking names already assigned within this batch + # to avoid collisions when multiple flows would get the same generated name + used_names_in_batch: set[str] = set() for flow in flow_list.flows: flow_name = await generate_unique_flow_name(flow.name, current_user.id, session) + # Ensure the name is also unique within the current batch; + # generate suffixed candidates and verify each against DB + base_name = flow_name + n = 1 + while flow_name in used_names_in_batch: + candidate = f"{base_name} ({n})" + n += 1 + flow_name = await generate_unique_flow_name(candidate, current_user.id, session) + used_names_in_batch.add(flow_name) flow.name = flow_name flow.user_id = current_user.id flow.folder_id = new_project.id diff --git a/src/backend/tests/unit/test_database.py b/src/backend/tests/unit/test_database.py index 4af8a67ca3..7cf9b093ab 100644 --- a/src/backend/tests/unit/test_database.py +++ b/src/backend/tests/unit/test_database.py @@ -1,4 +1,6 @@ +import io import json +import zipfile from datetime import datetime, timezone from typing import NamedTuple from uuid import UUID, uuid4 @@ -563,6 +565,401 @@ async def test_download_file( assert "attachment; filename=" in response.headers["Content-Disposition"] +@pytest.mark.usefixtures("session") +async def test_upload_zip_file_to_flows(client: AsyncClient, json_flow: str, logged_in_headers): + """Test uploading a ZIP file containing flow JSONs to the flows upload endpoint.""" + flow = orjson.loads(json_flow) + data = flow["data"] + + flow_1_name = str(uuid4()) + flow_2_name = str(uuid4()) + flow_1 = {"name": flow_1_name, "description": "desc1", "data": data} + flow_2 = {"name": flow_2_name, "description": "desc2", "data": data} + + # Create a ZIP file in memory with individual flow JSONs + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr(f"{flow_1_name}.json", json.dumps(flow_1)) + zf.writestr(f"{flow_2_name}.json", json.dumps(flow_2)) + zip_buffer.seek(0) + + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("flows.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 2 + names = {r["name"] for r in response_data} + assert flow_1_name in names + assert flow_2_name in names + + +@pytest.mark.usefixtures("session") +async def test_upload_zip_file_to_projects(client: AsyncClient, json_flow: str, logged_in_headers): + """Test uploading a ZIP file containing flow JSONs to the projects upload endpoint.""" + flow = orjson.loads(json_flow) + data = flow["data"] + + flow_1_name = str(uuid4()) + flow_2_name = str(uuid4()) + flow_1 = {"name": flow_1_name, "description": "desc1", "data": data} + flow_2 = {"name": flow_2_name, "description": "desc2", "data": data} + + # Create a ZIP file in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr(f"{flow_1_name}.json", json.dumps(flow_1)) + zf.writestr(f"{flow_2_name}.json", json.dumps(flow_2)) + zip_buffer.seek(0) + + response = await client.post( + "api/v1/projects/upload/", + files={"file": ("My Project.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 2 + # All flows should belong to the same folder + folder_ids = {r["folder_id"] for r in response_data} + assert len(folder_ids) == 1 + + # Verify the project name was derived from the ZIP filename + folder_id = folder_ids.pop() + project_response = await client.get(f"api/v1/projects/{folder_id}", headers=logged_in_headers) + assert project_response.status_code == 200 + assert project_response.json()["name"].startswith("My Project") + + +@pytest.mark.usefixtures("session") +async def test_upload_empty_zip_returns_400(client: AsyncClient, logged_in_headers): + """Test that uploading a ZIP with no JSON files returns 400.""" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("readme.txt", "not a flow") + zip_buffer.seek(0) + + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("empty.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert "No valid flow JSON files" in response.json()["detail"] + + +@pytest.mark.usefixtures("session") +async def test_download_then_upload_roundtrip(client: AsyncClient, json_flow: str, active_user, logged_in_headers): + """Test that downloading flows as ZIP and re-uploading works end-to-end.""" + flow = orjson.loads(json_flow) + data = flow["data"] + + flow_1_name = str(uuid4()) + flow_2_name = str(uuid4()) + flow_list = FlowListCreate( + flows=[ + FlowCreate(name=flow_1_name, description="description", data=data), + FlowCreate(name=flow_2_name, description="description", data=data), + ] + ) + + # Create flows in DB + db_manager = get_db_service() + async with session_getter(db_manager) as _session: + saved_flows = [] + for f in flow_list.flows: + f.user_id = active_user.id + db_flow = Flow.model_validate(f, from_attributes=True) + _session.add(db_flow) + saved_flows.append(db_flow) + await _session.commit() + flow_ids = [str(db_flow.id) for db_flow in saved_flows] + + # Download as ZIP + download_response = await client.post( + "api/v1/flows/download/", + data=json.dumps(flow_ids), + headers={**logged_in_headers, "Content-Type": "application/json"}, + ) + assert download_response.status_code == 200 + assert download_response.headers["Content-Type"] == "application/x-zip-compressed" + + # Re-upload the ZIP + upload_response = await client.post( + "api/v1/flows/upload/", + files={"file": ("flows.zip", download_response.content, "application/zip")}, + headers=logged_in_headers, + ) + assert upload_response.status_code == 201 + uploaded = upload_response.json() + assert len(uploaded) == 2 + + +@pytest.mark.usefixtures("session") +async def test_upload_zip_with_invalid_json(client: AsyncClient, json_flow: str, logged_in_headers): + """ZIP entries with invalid JSON are skipped; valid entries are still processed.""" + flow = orjson.loads(json_flow) + data = flow["data"] + valid_flow = {"name": "valid_flow", "description": "good", "data": data} + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("valid.json", json.dumps(valid_flow)) + zf.writestr("broken.json", "{not valid json!!!}") + zip_buffer.seek(0) + + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("mixed.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["name"] == "valid_flow" + + +@pytest.mark.usefixtures("session") +async def test_upload_zip_exceeding_max_entries(client: AsyncClient, json_flow: str, logged_in_headers, monkeypatch): + """ZIP with more JSON entries than the limit raises 400.""" + import langflow.api.utils.zip_utils as zip_utils_mod + + monkeypatch.setattr(zip_utils_mod, "MAX_ZIP_ENTRIES", 3) + + flow = orjson.loads(json_flow) + data = flow["data"] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + for i in range(5): + zf.writestr(f"flow_{i}.json", json.dumps({"name": f"flow_{i}", "data": data})) + zip_buffer.seek(0) + + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("too_many.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert "exceeding the limit" in response.json()["detail"] + + +@pytest.mark.usefixtures("session") +async def test_upload_zip_with_oversized_entry(client: AsyncClient, json_flow: str, logged_in_headers, monkeypatch): + """Entries exceeding size limit are skipped; smaller valid entries are processed.""" + import langflow.api.utils.zip_utils as zip_utils_mod + + flow = orjson.loads(json_flow) + data = flow["data"] + small_flow = {"name": "small_flow", "data": {"nodes": [], "edges": []}} + big_flow = {"name": "big_flow", "data": data} + + # Build the ZIP first, then set the limit between the two entry sizes + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("small.json", json.dumps(small_flow)) + zf.writestr("big.json", json.dumps(big_flow)) + + # Re-read to find the actual sizes and pick a limit between them + with zipfile.ZipFile(io.BytesIO(zip_buffer.getvalue()), "r") as zf: + sizes = {info.filename: info.file_size for info in zf.infolist()} + limit = (sizes["small.json"] + sizes["big.json"]) // 2 + monkeypatch.setattr(zip_utils_mod, "MAX_ENTRY_UNCOMPRESSED_BYTES", limit) + + zip_buffer.seek(0) + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("oversized.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["name"] == "small_flow" + + +@pytest.mark.usefixtures("session") +async def test_upload_zip_with_mixed_valid_invalid(client: AsyncClient, json_flow: str, logged_in_headers, monkeypatch): + """Mix of valid flows, invalid JSON, and oversized entries → only valid flows returned.""" + import langflow.api.utils.zip_utils as zip_utils_mod + + flow = orjson.loads(json_flow) + data = flow["data"] + valid_flow = {"name": "keeper", "data": {"nodes": [], "edges": []}} + oversized_flow = {"name": "too_big", "data": data, "padding": "x" * 500} + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("valid.json", json.dumps(valid_flow)) + zf.writestr("broken.json", "NOT JSON {{{") + zf.writestr("huge.json", json.dumps(oversized_flow)) + zf.writestr("readme.txt", "ignored non-json") + + # Set limit between valid entry size and oversized entry size + with zipfile.ZipFile(io.BytesIO(zip_buffer.getvalue()), "r") as zf: + sizes = {info.filename: info.file_size for info in zf.infolist()} + limit = (sizes["valid.json"] + sizes["huge.json"]) // 2 + monkeypatch.setattr(zip_utils_mod, "MAX_ENTRY_UNCOMPRESSED_BYTES", limit) + + zip_buffer.seek(0) + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("mixed.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["name"] == "keeper" + + +@pytest.mark.usefixtures("session") +async def test_upload_zip_to_projects_filename_none(client: AsyncClient, json_flow: str, logged_in_headers): + """When filename has no stem (e.g. '.zip'), the project name defaults to 'Imported Project'.""" + flow = orjson.loads(json_flow) + data = flow["data"] + flow_data = {"name": "flow_none", "data": data} + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("flow.json", json.dumps(flow_data)) + zip_buffer.seek(0) + + # filename=".zip" → rsplit gives ("", "zip") → "" is falsy → "Imported Project" + response = await client.post( + "api/v1/projects/upload/", + files={"file": (".zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 1 + + folder_id = response_data[0]["folder_id"] + project_response = await client.get(f"api/v1/projects/{folder_id}", headers=logged_in_headers) + assert project_response.status_code == 200 + assert project_response.json()["name"].startswith("Imported Project") + + +@pytest.mark.usefixtures("session") +async def test_upload_bad_zip_file_returns_400(client: AsyncClient, logged_in_headers): + """Uploading a corrupt/invalid ZIP file returns 400 with a descriptive error.""" + # Build a payload that passes zipfile.is_zipfile() but fails ZipFile() construction. + # We keep only the end-of-central-directory record (last 22 bytes of a real ZIP) + # prepended with garbage, so the EOCD signature is found but the central directory is invalid. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("dummy.json", '{"name":"x"}') + valid_zip = buf.getvalue() + # Minimal EOCD is 22 bytes; keep it and prepend garbage + corrupt_zip = b"garbage" * 10 + valid_zip[-22:] + + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("corrupt.zip", corrupt_zip, "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert "not a valid ZIP" in response.json()["detail"] + + +@pytest.mark.usefixtures("session") +async def test_upload_no_file_to_flows_returns_400(client: AsyncClient, logged_in_headers): + """Uploading with no file to flows endpoint returns 400.""" + response = await client.post( + "api/v1/flows/upload/", + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "No file provided" + + +@pytest.mark.usefixtures("session") +async def test_upload_no_file_to_projects_returns_400(client: AsyncClient, logged_in_headers): + """Uploading with no file to projects endpoint returns 400.""" + response = await client.post( + "api/v1/projects/upload/", + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "No file provided" + + +@pytest.mark.usefixtures("session") +async def test_upload_empty_file_to_flows_returns_400(client: AsyncClient, logged_in_headers): + """Uploading an empty file to flows endpoint returns 400.""" + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("empty.json", b"", "application/json")}, + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "The uploaded file is empty" + + +@pytest.mark.usefixtures("session") +async def test_upload_empty_file_to_projects_returns_400(client: AsyncClient, logged_in_headers): + """Uploading an empty file to projects endpoint returns 400.""" + response = await client.post( + "api/v1/projects/upload/", + files={"file": ("empty.json", b"", "application/json")}, + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "The uploaded file is empty" + + +@pytest.mark.usefixtures("session") +async def test_upload_invalid_json_to_flows_returns_400(client: AsyncClient, logged_in_headers): + """Uploading invalid JSON content to flows endpoint returns 400.""" + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("bad.json", b"this is not json", "application/json")}, + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert "Invalid JSON file" in response.json()["detail"] + + +@pytest.mark.usefixtures("session") +async def test_upload_invalid_json_to_projects_returns_400(client: AsyncClient, logged_in_headers): + """Uploading invalid JSON content to projects endpoint returns 400.""" + response = await client.post( + "api/v1/projects/upload/", + files={"file": ("bad.json", b"this is not json", "application/json")}, + headers=logged_in_headers, + ) + assert response.status_code == 400 + assert "Invalid JSON file" in response.json()["detail"] + + +@pytest.mark.usefixtures("session") +async def test_upload_zip_to_projects_batch_name_dedup(client: AsyncClient, json_flow: str, logged_in_headers): + """Multiple flows with the same name get unique names within the batch.""" + flow = orjson.loads(json_flow) + data = flow["data"] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + for i in range(3): + zf.writestr(f"flow_{i}.json", json.dumps({"name": "duplicate_name", "data": data})) + zip_buffer.seek(0) + + response = await client.post( + "api/v1/projects/upload/", + files={"file": ("dedup_test.zip", zip_buffer.getvalue(), "application/zip")}, + headers=logged_in_headers, + ) + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 3 + names = [r["name"] for r in response_data] + # All names must be unique + assert len(set(names)) == 3 + + @pytest.mark.usefixtures("active_user") async def test_create_flow_with_invalid_data(client: AsyncClient, logged_in_headers): flow = {"name": "a" * 256, "data": "Invalid flow data"}