fix: support ZIP file upload for flows and projects endpoints (#12253)

* feat: support ZIP file upload for flows and projects endpoints

Add ZIP upload support to both /flows/upload/ and /projects/upload/
endpoints, enabling round-trip download-then-upload workflows. Extract
shared ZIP parsing logic into a dedicated utility with zip bomb
protections (entry count and file size limits). Fix batch flow name
deduplication to avoid infinite loops and DB collisions. Add tests for
ZIP upload, empty ZIP rejection, and download-upload round-trip.

* fix: add type annotation to satisfy mypy union narrowing

* fix: address PR review for ZIP upload (#12253)

- Add BadZipFile handling in extract_flows_from_zip for defense-in-depth
- Wrap blocking ZIP I/O in asyncio.to_thread() to avoid event loop blocking
- Add post-read size check to guard against dishonest ZIP headers
- Add 7 adversarial tests: invalid JSON, max entries, oversized entries,
  mixed valid/invalid, filename fallback, corrupt ZIP, batch name dedup

* fix: return 400 instead of 500 for missing, empty, or invalid file uploads

Upload endpoints were returning 500 when no file was provided or when the
file contained empty/invalid content. Now properly validates and returns
400 with descriptive messages for: no file provided, empty file, and
invalid JSON content.
This commit is contained in:
Viktor Avelino
2026-03-23 14:09:26 -04:00
committed by GitHub
parent fb6b069dc2
commit b288b3971f
4 changed files with 559 additions and 9 deletions

View File

@ -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

View File

@ -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)])

View File

@ -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

View File

@ -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"}