Compare commits

...

5 Commits

Author SHA1 Message Date
QuantumGhost
737d290fdc test(api): Simplify unit tests
Remove duplicated test cases.
2025-09-11 17:42:30 +08:00
QuantumGhost
81d9a9d00d feat(api): limit conversation variable description length
Previously, conversation variable descriptions had no length limit, causing errors
when debugging chatflows. These descriptions are saved as draft variable descriptions
during debugging, which have a 256-character limit. Variables with descriptions
exceeding this limit could not be saved, resulting in 500 errors and "Internal
Server Error" messages during chatflow debugging.

This commit implements description length limits for conversation variables:

- New chatflows: Hard limit of 256 characters for descriptions. Longer
  descriptions are rejected and prevent chatflow saving.
- Existing chatflows: If all conversation variable descriptions are under
  256 characters, the same limit applies as new chatflows. Otherwise, no limit
  is enforced to maintain backward compatibility.
2025-09-11 17:42:30 +08:00
JzoNg
215e318600 slice descrition when restore workflow 2025-09-11 17:41:35 +08:00
JzoNg
53faf90bea fix conversation var restore & description length check 2025-09-11 17:41:34 +08:00
JzoNg
d2f12037da add description length check 2025-09-11 17:41:34 +08:00
6 changed files with 374 additions and 9 deletions

View File

@@ -1,3 +1,6 @@
from libs.exception import BaseHTTPException
class WorkflowInUseError(ValueError):
"""Raised when attempting to delete a workflow that's in use by an app"""
@@ -8,3 +11,17 @@ class DraftWorkflowDeletionError(ValueError):
"""Raised when attempting to delete a draft workflow"""
pass
class ConversationVariableDescriptionTooLongError(BaseHTTPException):
"""Raised when conversation variable description exceeds maximum length"""
error_code = "conversation_variable_description_too_long"
code = 400
def __init__(self, current_length: int, max_length: int):
description = (
f"Conversation variable description exceeds maximum length of "
f"{max_length} characters. Current length: {current_length} characters."
)
super().__init__(description)

View File

@@ -42,8 +42,16 @@ from services.enterprise.plugin_manager_service import PluginCredentialType
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError
from services.workflow.workflow_converter import WorkflowConverter
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
from .errors.workflow_service import (
ConversationVariableDescriptionTooLongError,
DraftWorkflowDeletionError,
WorkflowInUseError,
)
from .workflow_draft_variable_service import (
DraftVariableSaver,
DraftVarLoader,
WorkflowDraftVariableService,
)
class WorkflowService:
@@ -189,6 +197,42 @@ class WorkflowService:
return workflows, has_more
def _validate_conversation_variable_descriptions(
self,
conversation_variables: Sequence[Variable],
existing_workflow: Workflow | None = None,
) -> None:
"""
Validate conversation variable descriptions for length constraints.
Args:
conversation_variables: List of conversation variables to validate
existing_workflow: Existing workflow for backward compatibility check (optional)
Raises:
ConversationVariableDescriptionTooLongError: If any description exceeds the length limit and shouldn't be allowed
"""
MAX_CONVERSATION_VARIABLE_DESCRIPTION_LENGTH = 256
# Check if existing workflow has any conversation variables with long descriptions
has_existing_long_descriptions = False
if existing_workflow and existing_workflow.conversation_variables:
for var in existing_workflow.conversation_variables:
if len(var.description) > MAX_CONVERSATION_VARIABLE_DESCRIPTION_LENGTH:
has_existing_long_descriptions = True
break
# Validate new conversation variables
for variable in conversation_variables:
description_length = len(variable.description)
if description_length > MAX_CONVERSATION_VARIABLE_DESCRIPTION_LENGTH:
# Allow existing workflows with long descriptions to be updated
# But don't allow new long descriptions or extending existing ones
if not has_existing_long_descriptions:
raise ConversationVariableDescriptionTooLongError(
current_length=description_length, max_length=MAX_CONVERSATION_VARIABLE_DESCRIPTION_LENGTH
)
def sync_draft_workflow(
self,
*,
@@ -213,6 +257,12 @@ class WorkflowService:
# validate features structure
self.validate_features_structure(app_model=app_model, features=features)
# validate conversation variable descriptions
self._validate_conversation_variable_descriptions(
conversation_variables=conversation_variables,
existing_workflow=workflow,
)
# create draft workflow if not found
if not workflow:
workflow = Workflow(

View File

@@ -1,26 +1,40 @@
from unittest.mock import MagicMock
import json
from unittest.mock import MagicMock, patch
import pytest
from core.variables.variables import StringVariable
from models.account import Account
from models.model import App
from models.workflow import Workflow
from services.errors.workflow_service import ConversationVariableDescriptionTooLongError
from services.workflow_service import WorkflowService
class TestWorkflowService:
@pytest.fixture
def workflow_service(self):
mock_session_maker = MagicMock()
return WorkflowService(mock_session_maker)
# Mock the database dependencies to avoid Flask app context issues
with patch("services.workflow_service.db") as mock_db:
with patch("services.workflow_service.sessionmaker") as mock_sessionmaker:
mock_db.engine = MagicMock()
mock_sessionmaker.return_value = MagicMock()
return WorkflowService()
@pytest.fixture
def mock_app(self):
app = MagicMock(spec=App)
app.id = "app-id-1"
app.workflow_id = "workflow-id-1"
app.tenant_id = "tenant-id-1"
app.mode = "workflow"
return app
@pytest.fixture
def mock_account(self):
account = MagicMock(spec=Account)
account.id = "user-id-1"
return account
@pytest.fixture
def mock_workflows(self):
workflows = []
@@ -47,6 +61,7 @@ class TestWorkflowService:
mock_session.scalars.assert_not_called()
def test_get_all_published_workflow_basic(self, workflow_service, mock_app, mock_workflows):
mock_app.workflow_id = "workflow-id-1"
mock_session = MagicMock()
mock_scalar_result = MagicMock()
mock_scalar_result.all.return_value = mock_workflows[:3]
@@ -61,6 +76,7 @@ class TestWorkflowService:
mock_session.scalars.assert_called_once()
def test_get_all_published_workflow_pagination(self, workflow_service, mock_app, mock_workflows):
mock_app.workflow_id = "workflow-id-1"
mock_session = MagicMock()
mock_scalar_result = MagicMock()
# Return 4 items when limit is 3, which should indicate has_more=True
@@ -88,6 +104,7 @@ class TestWorkflowService:
assert has_more is False
def test_get_all_published_workflow_user_filter(self, workflow_service, mock_app, mock_workflows):
mock_app.workflow_id = "workflow-id-1"
mock_session = MagicMock()
mock_scalar_result = MagicMock()
# Filter workflows for user-id-1
@@ -108,6 +125,7 @@ class TestWorkflowService:
assert "created_by" in str(args)
def test_get_all_published_workflow_named_only(self, workflow_service, mock_app, mock_workflows):
mock_app.workflow_id = "workflow-id-1"
mock_session = MagicMock()
mock_scalar_result = MagicMock()
# Filter workflows that have a marked_name
@@ -128,6 +146,7 @@ class TestWorkflowService:
assert "marked_name !=" in str(args)
def test_get_all_published_workflow_combined_filters(self, workflow_service, mock_app, mock_workflows):
mock_app.workflow_id = "workflow-id-1"
mock_session = MagicMock()
mock_scalar_result = MagicMock()
# Combined filter: user-id-1 and has marked_name
@@ -149,6 +168,7 @@ class TestWorkflowService:
assert "marked_name !=" in str(args)
def test_get_all_published_workflow_empty_result(self, workflow_service, mock_app):
mock_app.workflow_id = "workflow-id-1"
mock_session = MagicMock()
mock_scalar_result = MagicMock()
mock_scalar_result.all.return_value = []
@@ -161,3 +181,267 @@ class TestWorkflowService:
assert workflows == []
assert has_more is False
mock_session.scalars.assert_called_once()
class TestSyncDraftWorkflow:
"""Test cases focused on the sync_draft_workflow method."""
@pytest.fixture
def workflow_service(self):
# Mock the database dependencies to avoid Flask app context issues
with patch("services.workflow_service.db") as mock_db:
with patch("services.workflow_service.sessionmaker") as mock_sessionmaker:
mock_db.engine = MagicMock()
mock_sessionmaker.return_value = MagicMock()
return WorkflowService()
@pytest.fixture
def mock_app(self):
app = MagicMock(spec=App)
app.id = "app-id-1"
app.tenant_id = "tenant-id-1"
app.mode = "workflow"
return app
@pytest.fixture
def mock_account(self):
account = MagicMock(spec=Account)
account.id = "user-id-1"
return account
@pytest.fixture
def sample_graph(self):
return {"nodes": [], "edges": []}
@pytest.fixture
def sample_features(self):
return {"retriever_resource": {"enabled": True}}
@pytest.fixture
def sample_environment_variables(self):
return []
@pytest.fixture
def sample_conversation_variables(self):
return [
StringVariable(
id="var-1", name="test_var", description="A valid description within limits", value="test_value"
)
]
@patch("services.workflow_service.db.session")
@patch("services.workflow_service.app_draft_workflow_was_synced")
def test_sync_draft_workflow_creates_new_workflow(
self,
mock_signal,
mock_db_session,
workflow_service,
mock_app,
mock_account,
sample_graph,
sample_features,
sample_environment_variables,
sample_conversation_variables,
):
"""Test that sync_draft_workflow creates a new workflow when none exists."""
# Mock get_draft_workflow to return None (no existing workflow)
with patch.object(workflow_service, "get_draft_workflow", return_value=None):
with patch.object(workflow_service, "validate_features_structure"):
result = workflow_service.sync_draft_workflow(
app_model=mock_app,
graph=sample_graph,
features=sample_features,
unique_hash="test-hash",
account=mock_account,
environment_variables=sample_environment_variables,
conversation_variables=sample_conversation_variables,
)
# Verify a new workflow was created
assert result is not None
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
mock_signal.send.assert_called_once()
@patch("services.workflow_service.db.session")
@patch("services.workflow_service.app_draft_workflow_was_synced")
def test_sync_draft_workflow_updates_existing_workflow(
self,
mock_signal,
mock_db_session,
workflow_service,
mock_app,
mock_account,
sample_graph,
sample_features,
sample_environment_variables,
sample_conversation_variables,
):
"""Test that sync_draft_workflow updates an existing workflow."""
# Mock existing workflow
existing_workflow = MagicMock(spec=Workflow)
existing_workflow.unique_hash = "test-hash"
existing_workflow.conversation_variables = []
with patch.object(workflow_service, "get_draft_workflow", return_value=existing_workflow):
with patch.object(workflow_service, "validate_features_structure"):
result = workflow_service.sync_draft_workflow(
app_model=mock_app,
graph=sample_graph,
features=sample_features,
unique_hash="test-hash",
account=mock_account,
environment_variables=sample_environment_variables,
conversation_variables=sample_conversation_variables,
)
# Verify existing workflow was updated
assert result == existing_workflow
assert existing_workflow.graph == json.dumps(sample_graph)
assert existing_workflow.features == json.dumps(sample_features)
assert existing_workflow.updated_by == mock_account.id
assert existing_workflow.environment_variables == sample_environment_variables
assert existing_workflow.conversation_variables == sample_conversation_variables
mock_db_session.add.assert_not_called() # Should not add new workflow
mock_db_session.commit.assert_called_once()
mock_signal.send.assert_called_once()
def test_sync_draft_workflow_validates_conversation_variable_descriptions(
self, workflow_service, mock_app, mock_account, sample_graph, sample_features, sample_environment_variables
):
"""Test that sync_draft_workflow validates conversation variable descriptions."""
# Create conversation variable with description exceeding limit
long_description = "a" * 300 # Exceeds 256 character limit
long_desc_variables = [
StringVariable(id="var-1", name="test_var", description=long_description, value="test_value")
]
with patch.object(workflow_service, "get_draft_workflow", return_value=None):
with patch.object(workflow_service, "validate_features_structure"):
with pytest.raises(ConversationVariableDescriptionTooLongError) as exc_info:
workflow_service.sync_draft_workflow(
app_model=mock_app,
graph=sample_graph,
features=sample_features,
unique_hash="test-hash",
account=mock_account,
environment_variables=sample_environment_variables,
conversation_variables=long_desc_variables,
)
assert "exceeds maximum length of 256 characters" in str(exc_info.value)
assert "Current length: 300 characters" in str(exc_info.value)
def test_sync_draft_workflow_allows_existing_long_descriptions(
self, workflow_service, mock_app, mock_account, sample_graph, sample_features, sample_environment_variables
):
"""Test that sync_draft_workflow allows updates when existing workflow has long descriptions."""
# Create existing workflow with long description
long_description = "a" * 300
existing_variable = StringVariable(
id="existing-var", name="existing_var", description=long_description, value="existing_value"
)
existing_workflow = MagicMock(spec=Workflow)
existing_workflow.unique_hash = "test-hash"
existing_workflow.conversation_variables = [existing_variable]
# New variables with long descriptions should be allowed
new_long_desc_variables = [
StringVariable(id="new-var", name="new_var", description=long_description, value="new_value")
]
with patch.object(workflow_service, "get_draft_workflow", return_value=existing_workflow):
with patch.object(workflow_service, "validate_features_structure"):
with patch("services.workflow_service.db.session"):
with patch("services.workflow_service.app_draft_workflow_was_synced"):
# Should not raise exception
result = workflow_service.sync_draft_workflow(
app_model=mock_app,
graph=sample_graph,
features=sample_features,
unique_hash="test-hash",
account=mock_account,
environment_variables=sample_environment_variables,
conversation_variables=new_long_desc_variables,
)
assert result == existing_workflow
def test_sync_draft_workflow_handles_empty_conversation_variables(
self, workflow_service, mock_app, mock_account, sample_graph, sample_features, sample_environment_variables
):
"""Test that empty conversation variables list is handled correctly."""
with patch.object(workflow_service, "get_draft_workflow", return_value=None):
with patch.object(workflow_service, "validate_features_structure"):
with patch("services.workflow_service.db.session"):
with patch("services.workflow_service.app_draft_workflow_was_synced"):
result = workflow_service.sync_draft_workflow(
app_model=mock_app,
graph=sample_graph,
features=sample_features,
unique_hash="test-hash",
account=mock_account,
environment_variables=sample_environment_variables,
conversation_variables=[],
)
assert result is not None
def test_sync_draft_workflow_handles_multiple_variables_one_invalid(
self, workflow_service, mock_app, mock_account, sample_graph, sample_features, sample_environment_variables
):
"""Test that validation fails when one of multiple variables exceeds limit."""
mixed_variables = [
StringVariable(id="var-1", name="valid_var", description="Valid description", value="valid_value"),
StringVariable(
id="var-2",
name="invalid_var",
description="a" * 300, # Exceeds limit
value="invalid_value",
),
StringVariable(
id="var-3",
name="another_valid_var",
description="Another valid description",
value="another_valid_value",
),
]
with patch.object(workflow_service, "get_draft_workflow", return_value=None):
with patch.object(workflow_service, "validate_features_structure"):
with pytest.raises(ConversationVariableDescriptionTooLongError) as exc_info:
workflow_service.sync_draft_workflow(
app_model=mock_app,
graph=sample_graph,
features=sample_features,
unique_hash="test-hash",
account=mock_account,
environment_variables=sample_environment_variables,
conversation_variables=mixed_variables,
)
assert "exceeds maximum length of 256 characters" in str(exc_info.value)
assert "Current length: 300 characters" in str(exc_info.value)
def test_sync_draft_workflow_handles_empty_descriptions(
self, workflow_service, mock_app, mock_account, sample_graph, sample_features, sample_environment_variables
):
"""Test that empty description strings are handled correctly."""
empty_desc_variables = [StringVariable(id="var-1", name="empty_desc_var", description="", value="test_value")]
with patch.object(workflow_service, "get_draft_workflow", return_value=None):
with patch.object(workflow_service, "validate_features_structure"):
with patch("services.workflow_service.db.session"):
with patch("services.workflow_service.app_draft_workflow_was_synced"):
# Should not raise exception
result = workflow_service.sync_draft_workflow(
app_model=mock_app,
graph=sample_graph,
features=sample_features,
unique_hash="test-hash",
account=mock_account,
environment_variables=sample_environment_variables,
conversation_variables=empty_desc_variables,
)
assert result is not None

View File

@@ -340,7 +340,17 @@ export const useWorkflowRun = () => {
}
featuresStore?.setState({ features: mappedFeatures })
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
// slice description of env & chatVar
const newEnvList = (publishedWorkflow.environment_variables || []).map(env => ({
...env,
description: env.description.slice(0, 256),
}))
const newChatVarList = (publishedWorkflow.conversation_variables || []).map(chatVar => ({
...chatVar,
description: chatVar.description.slice(0, 256),
}))
workflowStore.getState().setEnvironmentVariables(newEnvList || [])
workflowStore.getState().setConversationVariables(newChatVarList || [])
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])
return {

View File

@@ -235,11 +235,13 @@ const ChatVariableModal = ({
return
if (!chatVar && varList.some(chatVar => chatVar.name === name))
return notify({ type: 'error', message: 'name is existed' })
// if (type !== ChatVarType.Object && !value)
// return notify({ type: 'error', message: 'value can not be empty' })
if (type === ChatVarType.Object && objectValue.some(item => !item.key && !!item.value))
return notify({ type: 'error', message: 'object key can not be empty' })
if (description.length > 256)
return notify({ type: 'error', message: 'description can not be longer than 256 characters' })
onSave({
id: chatVar ? chatVar.id : uuid4(),
name,

View File

@@ -62,6 +62,8 @@ const VariableModal = ({
// Original check for create new variable
if (!env && envList.some(e => e.name === name))
return notify({ type: 'error', message: 'name is existed' })
if (description.length > 256)
return notify({ type: 'error', message: 'description can not be longer than 256 characters' })
onSave({
id: env ? env.id : uuid4(),