mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 04:13:36 +08:00
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:
85
src/backend/base/langflow/api/utils/zip_utils.py
Normal file
85
src/backend/base/langflow/api/utils/zip_utils.py
Normal 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
|
||||
@ -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)])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user