Skip to content
2 changes: 1 addition & 1 deletion src/backend/base/langflow/api/utils/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def remove_api_keys(flow: dict):
node_data = node.get("data").get("node")
template = node_data.get("template")
for value in template.values():
if isinstance(value, dict) and has_api_terms(value["name"]) and value.get("password"):
if isinstance(value, dict) and "name" in value and has_api_terms(value["name"]) and value.get("password"):
value["value"] = None

return flow
Expand Down
10 changes: 5 additions & 5 deletions src/backend/base/langflow/services/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
# These imports MUST be outside TYPE_CHECKING because FastAPI uses eval_str=True
# to evaluate type annotations, and these types are used as return types for
# dependency functions that FastAPI evaluates at module load time.
from lfx.services.settings.service import SettingsService
from lfx.services.settings.service import SettingsService # noqa: TC002

from langflow.services.job_queue.service import JobQueueService
from langflow.services.storage.service import StorageService
from langflow.services.telemetry.service import TelemetryService
from langflow.services.job_queue.service import JobQueueService # noqa: TC001
from langflow.services.storage.service import StorageService # noqa: TC001
from langflow.services.telemetry.service import TelemetryService # noqa: TC001


def get_service(service_type: ServiceType, default=None):
Expand Down Expand Up @@ -176,7 +176,7 @@ async def session_scope() -> AsyncGenerator[AsyncSession, None]:
yield session


def get_cache_service() -> Union[CacheService, AsyncBaseCacheService]:
def get_cache_service() -> Union[CacheService, AsyncBaseCacheService]: # noqa: UP007
"""Retrieves the cache service from the service manager.

Returns:
Expand Down
128 changes: 127 additions & 1 deletion src/backend/tests/unit/api/test_api_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from unittest.mock import patch

from langflow.api.utils import get_suggestion_message
from langflow.api.utils import get_suggestion_message, remove_api_keys
from langflow.services.database.models.flow.utils import get_outdated_components
from langflow.utils.version import get_version_info

Expand Down Expand Up @@ -43,3 +43,129 @@ def test_get_outdated_components():
result = get_outdated_components(flow)
# Assert the result is as expected
assert result == expected_outdated_components


def test_remove_api_keys():
"""Test that remove_api_keys properly removes API keys and handles various template structures.
This test validates the fix for the bug where remove_api_keys would crash when
encountering template values without 'name' keys (e.g., Note components with
only backgroundColor).
"""
# Test case 1: Flow with API key that should be removed
flow_with_api_key = {
"data": {
"nodes": [
{
"data": {
"node": {
"template": {
"api_key": {
"name": "api_key",
"value": "secret-123",
"password": True,
},
"openai_api_key": {
"name": "openai_api_key",
"value": "sk-abc123",
"password": True,
},
}
}
}
}
]
}
}

result = remove_api_keys(flow_with_api_key)
assert result["data"]["nodes"][0]["data"]["node"]["template"]["api_key"]["value"] is None
assert result["data"]["nodes"][0]["data"]["node"]["template"]["openai_api_key"]["value"] is None

# Test case 2: Flow with Note component (no 'name' key) - this is the bug fix
flow_with_note = {
"data": {
"nodes": [
{
"data": {
"node": {
"template": {
"backgroundColor": {"value": "#ffffff"}, # No 'name' key
"text": {"value": "Test note"}, # No 'name' key
}
}
}
}
]
}
}

# This should not raise an error (the bug that was fixed)
result = remove_api_keys(flow_with_note)
# Values should be preserved since they're not API keys
assert result["data"]["nodes"][0]["data"]["node"]["template"]["backgroundColor"]["value"] == "#ffffff"
assert result["data"]["nodes"][0]["data"]["node"]["template"]["text"]["value"] == "Test note"

# Test case 3: Mixed flow with both API keys and template values without 'name'
mixed_flow = {
"data": {
"nodes": [
{
"data": {
"node": {
"template": {
"backgroundColor": {"value": "#ffffff"}, # No 'name' key
"api_token": {
"name": "api_token",
"value": "token-xyz",
"password": True,
},
"regular_field": {
"name": "regular_field",
"value": "keep-this",
},
}
}
}
}
]
}
}

result = remove_api_keys(mixed_flow)
# backgroundColor should be preserved (no 'name' key)
assert result["data"]["nodes"][0]["data"]["node"]["template"]["backgroundColor"]["value"] == "#ffffff"
# API token should be removed
assert result["data"]["nodes"][0]["data"]["node"]["template"]["api_token"]["value"] is None
# Regular field should be kept
assert result["data"]["nodes"][0]["data"]["node"]["template"]["regular_field"]["value"] == "keep-this"

# Test case 4: Flow with auth_token (password field but not password=True)
flow_with_non_password_api = {
"data": {
"nodes": [
{
"data": {
"node": {
"template": {
"api_key": {
"name": "api_key",
"value": "should-not-be-removed",
"password": False, # Not a password field
},
}
}
}
}
]
}
}

result = remove_api_keys(flow_with_non_password_api)
# Should NOT be removed because password is False
assert result["data"]["nodes"][0]["data"]["node"]["template"]["api_key"]["value"] == "should-not-be-removed"

# Test case 5: Empty flow
empty_flow = {"data": {"nodes": []}}
result = remove_api_keys(empty_flow)
assert result == empty_flow
181 changes: 181 additions & 0 deletions src/backend/tests/unit/api/v1/test_projects.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import io
import json
import zipfile
from unittest.mock import MagicMock, patch
from uuid import uuid4

import pytest
from fastapi import status
from httpx import AsyncClient
from langflow.initial_setup.constants import STARTER_FOLDER_NAME
from langflow.services.database.models.flow.model import Flow, FlowCreate
from langflow.services.deps import session_scope

CYRILLIC_NAME = "Новый проект"
CYRILLIC_DESC = "Описание проекта с кириллицей" # noqa: RUF001
Expand Down Expand Up @@ -1538,3 +1544,178 @@ async def test_read_project_error_handling_consistency(self, client: AsyncClient
assert "not found" in result["detail"].lower(), (
f"Error message should mention 'not found' for params: {params}"
)


async def test_download_file_starter_project(client: AsyncClient, logged_in_headers, active_user, json_flow):
"""Test downloading a project with multiple flows.

This test specifically validates:
1. The download endpoint returns a valid ZIP file with multiple flows
2. The remove_api_keys function handles flows with various template structures,
including components that don't have 'name' keys in their template values
(e.g., Note components with only backgroundColor)
3. API keys are removed from downloaded flows
4. Non-sensitive data is preserved in the download
"""
# Create a project for the user (since download_file requires user ownership)
project_payload = {
"name": STARTER_FOLDER_NAME,
"description": "Starter projects to help you get started in Langflow.",
"flows_list": [],
"components_list": [],
}
create_response = await client.post("api/v1/projects/", json=project_payload, headers=logged_in_headers)
assert create_response.status_code == status.HTTP_201_CREATED
starter_project = create_response.json()
starter_project_id = starter_project["id"]

# Create multiple flows in the project
flow_data = json.loads(json_flow)

# Create a flow with a Note component to test the bug fix
# Note components have template values without 'name' keys
flow_with_note = {
"nodes": [
{
"id": "note-1",
"type": "genericNode",
"data": {
"node": {
"template": {
"backgroundColor": {"value": "#ffffff"}, # No 'name' key
"text": {"value": "Test note"}, # No 'name' key
}
}
},
},
# Add a node with API keys to test removal
{
"id": "api-node-1",
"type": "genericNode",
"data": {
"node": {
"template": {
"api_key": {
"name": "api_key",
"value": "secret-key-123",
"password": True,
},
"regular_field": {"name": "regular_field", "value": "keep-this"},
}
}
},
},
],
"edges": [],
}

flows_created = []
async with session_scope() as session:
# Create 3 flows: 2 from basic example + 1 with Note component
for i in range(2):
flow_create = FlowCreate(
name=f"Starter Flow {i + 1}",
description=f"Test starter flow {i + 1}",
data=flow_data.get("data", {}),
folder_id=starter_project_id,
user_id=active_user.id,
)
flow = Flow.model_validate(flow_create, from_attributes=True)
session.add(flow)
flows_created.append(flow)

# Add flow with Note component
flow_create_note = FlowCreate(
name="Flow with Note",
description="Flow with Note component and API keys",
data=flow_with_note,
folder_id=starter_project_id,
user_id=active_user.id,
)
flow_note = Flow.model_validate(flow_create_note, from_attributes=True)
session.add(flow_note)
flows_created.append(flow_note)

await session.flush()
# Refresh to get IDs
for flow in flows_created:
await session.refresh(flow)
await session.commit()

# Download the starter project
response = await client.get(
f"api/v1/projects/download/{starter_project_id}",
headers=logged_in_headers,
)

# Verify response
assert response.status_code == status.HTTP_200_OK, response.text
assert response.headers["Content-Type"] == "application/x-zip-compressed"
assert "attachment" in response.headers["Content-Disposition"]
assert "filename" in response.headers["Content-Disposition"]
# The filename is URL-encoded in the header, so check for the project name
content_disposition = response.headers["Content-Disposition"]
assert (
STARTER_FOLDER_NAME.replace(" ", "%20") in content_disposition
or STARTER_FOLDER_NAME.replace(" ", "_") in content_disposition
)

# Verify zip file contents
zip_content = response.content
with zipfile.ZipFile(io.BytesIO(zip_content), "r") as zip_file:
file_names = zip_file.namelist()
# Should have 3 flow files
assert len(file_names) == 3, f"Expected 3 files in zip, got {len(file_names)}: {file_names}"

# Verify each basic flow file exists and contains valid JSON
for i in range(2):
expected_filename = f"Starter Flow {i + 1}.json"
assert expected_filename in file_names, f"Expected {expected_filename} in zip file"

# Read and verify flow content
flow_content = zip_file.read(expected_filename)
flow_json = json.loads(flow_content)
assert flow_json["name"] == f"Starter Flow {i + 1}"
assert flow_json["description"] == f"Test starter flow {i + 1}"

# Verify the flow with Note component
note_flow_filename = "Flow with Note.json"
assert note_flow_filename in file_names, f"Expected {note_flow_filename} in zip file"

# Read and verify the Note flow - this tests the bug fix
note_flow_content = zip_file.read(note_flow_filename)
note_flow_json = json.loads(note_flow_content)
assert note_flow_json["name"] == "Flow with Note"
assert note_flow_json["description"] == "Flow with Note component and API keys"

# Verify the flow has the expected structure
assert "data" in note_flow_json
assert "nodes" in note_flow_json["data"]
assert len(note_flow_json["data"]["nodes"]) == 2

# Find the API node and verify API key was removed
api_node = None
note_node = None
for node in note_flow_json["data"]["nodes"]:
if node["id"] == "api-node-1":
api_node = node
elif node["id"] == "note-1":
note_node = node

# Verify Note node exists and didn't cause errors (the bug fix)
assert note_node is not None, "Note node should exist in downloaded flow"
note_template = note_node["data"]["node"]["template"]
assert "backgroundColor" in note_template, "Note backgroundColor should be preserved"
assert "text" in note_template, "Note text should be preserved"

# Verify API key was removed but regular field was kept
assert api_node is not None, "API node should exist in downloaded flow"
api_template = api_node["data"]["node"]["template"]
assert "api_key" in api_template, "API key field should exist"
assert api_template["api_key"]["value"] is None, "API key value should be removed/null"
assert "regular_field" in api_template, "Regular field should be preserved"
assert api_template["regular_field"]["value"] == "keep-this", "Regular field value should be kept"

# Clean up: delete the project (which will cascade delete flows)
delete_response = await client.delete(f"api/v1/projects/{starter_project_id}", headers=logged_in_headers)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
2 changes: 1 addition & 1 deletion src/lfx/src/lfx/_assets/component_index.json

Large diffs are not rendered by default.