test: centralize typed runtime config overrides (#40857)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Asuka Minato
2026-08-20 13:30:04 +00:00
committed by GitHub
parent ccfb47d2c5
commit 502418f707
13 changed files with 307 additions and 309 deletions
+2
View File
@@ -1,6 +1,7 @@
from configs.extra.agent_backend_config import AgentBackendConfig
from configs.extra.archive_config import ArchiveStorageConfig
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
from configs.extra.logstore_config import LogStoreConfig
from configs.extra.notion_config import NotionConfig
from configs.extra.sentry_config import SentryConfig
from configs.extra.turnstile_config import TurnstileConfig
@@ -11,6 +12,7 @@ class ExtraServiceConfig(
AgentBackendConfig,
ArchiveStorageConfig,
KnowledgeFSConfig,
LogStoreConfig,
NotionConfig,
SentryConfig,
TurnstileConfig,
+11
View File
@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class LogStoreConfig(BaseSettings):
"""Migration controls for repositories backed by Aliyun LogStore."""
LOGSTORE_DUAL_WRITE_ENABLED: bool = False
# Keep workflow graphs in LogStore by default. Deployments may disable this
# while migrating large graph payloads to another persistence owner.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD: bool = True
@@ -1,12 +1,12 @@
import json
import logging
import os
import time
from typing import override
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from core.repositories.factory import WorkflowExecutionRepository
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from extensions.logstore.aliyun_logstore import AliyunLogStore
@@ -71,14 +71,12 @@ class LogstoreWorkflowExecutionRepository(WorkflowExecutionRepository):
triggered_from=triggered_from,
)
# Control flag for dual-write (write to both LogStore and SQL database)
# Set to True to enable dual-write for safe migration, False to use LogStore only
self._enable_dual_write = os.environ.get("LOGSTORE_DUAL_WRITE_ENABLED", "false").lower() == "true"
self._enable_dual_write = dify_config.LOGSTORE_DUAL_WRITE_ENABLED
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
self._enable_put_graph_field = os.environ.get("LOGSTORE_ENABLE_PUT_GRAPH_FIELD", "true").lower() == "true"
self._enable_put_graph_field = dify_config.LOGSTORE_ENABLE_PUT_GRAPH_FIELD
def _to_logstore_model(self, domain_model: WorkflowExecution) -> list[tuple[str, str]]:
"""
@@ -7,7 +7,6 @@ using Aliyun SLS LogStore with append-only writes and version control.
import json
import logging
import os
import time
from collections.abc import Sequence
from datetime import datetime
@@ -16,6 +15,7 @@ from typing import Any, override
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from core.ops.utils import JSON_DICT_ADAPTER
from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository
@@ -152,9 +152,9 @@ class LogstoreWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
triggered_from=triggered_from,
)
# Control flag for dual-write (write to both LogStore and SQL database)
# Set to True to enable dual-write for safe migration, False to use LogStore only
self._enable_dual_write = os.environ.get("LOGSTORE_DUAL_WRITE_ENABLED", "false").lower() == "true"
# Keep the migration switch on the typed application config so callers
# and tests share the same validated source.
self._enable_dual_write = dify_config.LOGSTORE_DUAL_WRITE_ENABLED
def _to_logstore_model(self, domain_model: WorkflowNodeExecution) -> Sequence[tuple[str, str]]:
logger.debug(
@@ -0,0 +1,25 @@
import pytest
from configs.extra.logstore_config import LogStoreConfig
from tests.unit_tests.configs._isolated_settings import InitSettingsOnly
class _IsolatedLogStoreConfig(InitSettingsOnly, LogStoreConfig):
pass
@pytest.mark.parametrize(
("raw_value", "expected"),
[
pytest.param("true", True, id="enabled"),
pytest.param("false", False, id="disabled"),
],
)
def test_logstore_migration_flags_parse_boolean_values(raw_value: str, expected: bool) -> None:
config = _IsolatedLogStoreConfig(
LOGSTORE_DUAL_WRITE_ENABLED=raw_value,
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=raw_value,
)
assert config.LOGSTORE_DUAL_WRITE_ENABLED is expected
assert config.LOGSTORE_ENABLE_PUT_GRAPH_FIELD is expected
@@ -2,7 +2,6 @@
Unit tests for inner_api auth decorators
"""
from unittest.mock import patch
from uuid import NAMESPACE_URL, uuid5
import pytest
@@ -11,7 +10,6 @@ from sqlalchemy import Engine, event
from sqlalchemy.orm import Session
from werkzeug.exceptions import HTTPException
from configs import dify_config
from controllers.inner_api.wraps import (
billing_inner_api_only,
enterprise_inner_api_only,
@@ -23,6 +21,16 @@ from models.enums import EndUserType
from models.model import EndUser
@pytest.fixture(autouse=True)
def _inner_api_config(config_overrides) -> None:
config_overrides(
INNER_API=True,
INNER_API_KEY="valid_key",
PLUGIN_DAEMON_KEY="plugin_key",
INNER_API_KEY_FOR_PLUGIN="valid_plugin_key",
)
def _stable_uuid(value: str) -> str:
return str(uuid5(NAMESPACE_URL, value))
@@ -40,14 +48,12 @@ class TestBillingInnerApiOnly:
# Act
with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
result = protected_view()
result = protected_view()
# Assert
assert result == "success"
def test_should_return_404_when_inner_api_disabled(self, app: Flask):
def test_should_return_404_when_inner_api_disabled(self, app: Flask, config_overrides):
"""Test that 404 is returned when INNER_API is disabled"""
# Arrange
@@ -56,11 +62,11 @@ class TestBillingInnerApiOnly:
return "success"
# Act & Assert
config_overrides(INNER_API=False)
with app.test_request_context():
with patch.object(dify_config, "INNER_API", False):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
def test_should_return_401_when_api_key_missing(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is missing"""
@@ -72,11 +78,9 @@ class TestBillingInnerApiOnly:
# Act & Assert
with app.test_request_context(headers={}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
def test_should_return_401_when_api_key_invalid(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is invalid"""
@@ -88,11 +92,9 @@ class TestBillingInnerApiOnly:
# Act & Assert
with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
class TestEnterpriseInnerApiOnly:
@@ -108,14 +110,12 @@ class TestEnterpriseInnerApiOnly:
# Act
with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
result = protected_view()
result = protected_view()
# Assert
assert result == "success"
def test_should_return_404_when_inner_api_disabled(self, app: Flask):
def test_should_return_404_when_inner_api_disabled(self, app: Flask, config_overrides):
"""Test that 404 is returned when INNER_API is disabled"""
# Arrange
@@ -124,11 +124,11 @@ class TestEnterpriseInnerApiOnly:
return "success"
# Act & Assert
config_overrides(INNER_API=False)
with app.test_request_context():
with patch.object(dify_config, "INNER_API", False):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
def test_should_return_401_when_api_key_missing(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is missing"""
@@ -140,11 +140,9 @@ class TestEnterpriseInnerApiOnly:
# Act & Assert
with app.test_request_context(headers={}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
def test_should_return_401_when_api_key_invalid(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is invalid"""
@@ -156,11 +154,9 @@ class TestEnterpriseInnerApiOnly:
# Act & Assert
with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
class TestInnerApiOnly:
@@ -172,22 +168,20 @@ class TestInnerApiOnly:
return "success"
with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
result = protected_view()
result = protected_view()
assert result == "success"
def test_should_return_404_when_inner_api_disabled(self, app: Flask):
def test_should_return_404_when_inner_api_disabled(self, app: Flask, config_overrides):
@inner_api_only
def protected_view():
return "success"
config_overrides(INNER_API=False)
with app.test_request_context():
with patch.object(dify_config, "INNER_API", False):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
def test_should_return_401_when_api_key_missing(self, app: Flask):
@inner_api_only
@@ -195,11 +189,9 @@ class TestInnerApiOnly:
return "success"
with app.test_request_context(headers={}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
def test_should_return_401_when_api_key_invalid(self, app: Flask):
@inner_api_only
@@ -207,17 +199,15 @@ class TestInnerApiOnly:
return "success"
with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
class TestEnterpriseInnerApiUserAuth:
"""Test enterprise_inner_api_user_auth decorator for HMAC-based user authentication"""
def test_should_pass_through_when_inner_api_disabled(self, app: Flask):
def test_should_pass_through_when_inner_api_disabled(self, app: Flask, config_overrides):
"""Test that request passes through when INNER_API is disabled"""
# Arrange
@@ -226,9 +216,9 @@ class TestEnterpriseInnerApiUserAuth:
return kwargs.get("user", "no_user")
# Act
config_overrides(INNER_API=False)
with app.test_request_context():
with patch.object(dify_config, "INNER_API", False):
result = protected_view()
result = protected_view()
# Assert
assert result == "no_user"
@@ -243,8 +233,7 @@ class TestEnterpriseInnerApiUserAuth:
# Act
with app.test_request_context(headers={}):
with patch.object(dify_config, "INNER_API", True):
result = protected_view()
result = protected_view()
# Assert
assert result == "no_user"
@@ -259,8 +248,7 @@ class TestEnterpriseInnerApiUserAuth:
# Act
with app.test_request_context(headers={"Authorization": "invalid_format"}):
with patch.object(dify_config, "INNER_API", True):
result = protected_view()
result = protected_view()
# Assert
assert result == "no_user"
@@ -281,8 +269,7 @@ class TestEnterpriseInnerApiUserAuth:
with app.test_request_context(
headers={"Authorization": "Bearer user123:wrong_signature", "X-Inner-Api-Key": "valid_key"}
):
with patch.object(dify_config, "INNER_API", True):
result = protected_view()
result = protected_view()
finally:
event.remove(sqlite_engine, "before_cursor_execute", fail_on_query)
@@ -321,8 +308,7 @@ class TestEnterpriseInnerApiUserAuth:
with app.test_request_context(
headers={"Authorization": f"Bearer {user_id}:{valid_signature}", "X-Inner-Api-Key": inner_api_key}
):
with patch.object(dify_config, "INNER_API", True):
result = protected_view()
result = protected_view()
# Assert
assert isinstance(result, EndUser)
@@ -344,14 +330,12 @@ class TestPluginInnerApiOnly:
# Act
with app.test_request_context(headers={"X-Inner-Api-Key": "valid_plugin_key"}):
with patch.object(dify_config, "PLUGIN_DAEMON_KEY", "plugin_key"):
with patch.object(dify_config, "INNER_API_KEY_FOR_PLUGIN", "valid_plugin_key"):
result = protected_view()
result = protected_view()
# Assert
assert result == "success"
def test_should_return_404_when_plugin_daemon_key_not_set(self, app: Flask):
def test_should_return_404_when_plugin_daemon_key_not_set(self, app: Flask, config_overrides):
"""Test that 404 is returned when PLUGIN_DAEMON_KEY is not set"""
# Arrange
@@ -360,11 +344,11 @@ class TestPluginInnerApiOnly:
return "success"
# Act & Assert
config_overrides(PLUGIN_DAEMON_KEY="")
with app.test_request_context():
with patch.object(dify_config, "PLUGIN_DAEMON_KEY", ""):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
def test_should_return_404_when_api_key_invalid(self, app: Flask):
"""Test that 404 is returned when X-Inner-Api-Key header is invalid (note: returns 404, not 401)"""
@@ -376,8 +360,6 @@ class TestPluginInnerApiOnly:
# Act & Assert
with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}):
with patch.object(dify_config, "PLUGIN_DAEMON_KEY", "plugin_key"):
with patch.object(dify_config, "INNER_API_KEY_FOR_PLUGIN", "valid_plugin_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
@@ -6,6 +6,12 @@ from collections.abc import Iterator
import pytest
from flask import Flask
@pytest.fixture(autouse=True)
def _swagger_config(config_overrides) -> None:
config_overrides(SWAGGER_UI_ENABLED=True)
USER_PROPERTY_SCHEMA = {
"description": (
"User identifier, unique within the application. This identifier scopes data access; resources created with "
@@ -154,14 +160,11 @@ def test_uuid_path_format_is_derived_from_route_converter():
}
def test_openapi_json_endpoints_render(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_openapi_json_endpoints_render():
from controllers.console import bp as console_bp
from controllers.service_api import bp as service_api_bp
from controllers.web import bp as web_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -190,12 +193,9 @@ def test_openapi_json_endpoints_render(monkeypatch: pytest.MonkeyPatch):
assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True
def test_service_document_file_routes_document_multipart_form_data(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_document_file_routes_document_multipart_form_data():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -236,12 +236,9 @@ def test_service_document_file_routes_document_multipart_form_data(monkeypatch:
assert update_operation["requestBody"]["required"] is False
def test_service_openapi_merges_public_api_reference_descriptions(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_merges_public_api_reference_descriptions():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -261,12 +258,9 @@ def test_service_openapi_merges_public_api_reference_descriptions(monkeypatch: p
assert _parameters_by_name(rename_operation)["c_id"]["description"] == "Conversation ID."
def test_service_document_list_documents_query_params_render(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_document_list_documents_query_params_render():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -280,12 +274,9 @@ def test_service_document_list_documents_query_params_render(monkeypatch: pytest
assert params[name]["in"] == "query"
def test_service_openapi_documents_decorator_user_contracts(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_documents_decorator_user_contracts():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -330,12 +321,9 @@ def test_service_openapi_documents_decorator_user_contracts(monkeypatch: pytest.
assert events_params["user"]["required"] is True
def test_service_openapi_documents_app_multipart_contracts(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_documents_app_multipart_contracts():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -365,12 +353,9 @@ def test_service_openapi_documents_app_multipart_contracts(monkeypatch: pytest.M
assert pipeline_schema["required"] == ["file"]
def test_service_openapi_documents_non_json_response_media_types(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_documents_non_json_response_media_types():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -409,12 +394,9 @@ def test_service_openapi_documents_non_json_response_media_types(monkeypatch: py
}
def test_service_openapi_documents_uuid_params_and_deprecated_routes(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_documents_uuid_params_and_deprecated_routes():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -441,12 +423,9 @@ def test_service_openapi_documents_uuid_params_and_deprecated_routes(monkeypatch
assert paths["/datasets/{dataset_id}/documents/{document_id}/update_by_text"]["post"]["deprecated"] is True
def test_service_openapi_documents_path_action_enums(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_documents_path_action_enums():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -465,12 +444,9 @@ def test_service_openapi_documents_path_action_enums(monkeypatch: pytest.MonkeyP
assert metadata_params["action"]["schema"]["enum"] == ["enable", "disable"]
def test_service_openapi_documents_conditional_payload_schemas(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_documents_conditional_payload_schemas():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -497,12 +473,9 @@ def test_service_openapi_documents_conditional_payload_schemas(monkeypatch: pyte
assert without_text_branch["properties"]["text"]["type"] == "null"
def test_service_openapi_does_not_encode_docs_coverage_boundaries(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_does_not_encode_docs_coverage_boundaries():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -525,12 +498,9 @@ def test_service_openapi_does_not_encode_docs_coverage_boundaries(monkeypatch: p
assert paths["/datasets/{dataset_id}/documents/{document_id}/update-by-file"]["post"]["deprecated"] is True
def test_service_openapi_documents_auth_and_compatibility_payloads(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_service_openapi_documents_auth_and_compatibility_payloads():
from controllers.service_api import bp as service_api_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -556,12 +526,9 @@ def test_service_openapi_documents_auth_and_compatibility_payloads(monkeypatch:
assert tag_ids_schema["required"] == ["tag_ids", "target_id"]
def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_console_account_avatar_query_param_renders_as_query():
from controllers.console import bp as console_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -576,12 +543,9 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest
assert params["avatar"]["required"] is True
def test_console_account_profile_patch_and_deprecated_aliases(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_console_account_profile_patch_and_deprecated_aliases():
from controllers.console import bp as console_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -610,12 +574,9 @@ def test_console_account_profile_patch_and_deprecated_aliases(monkeypatch: pytes
assert paths["/account/avatar"]["get"].get("deprecated") is not True
def test_console_agent_debug_conversation_refresh_has_no_body(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_console_agent_debug_conversation_refresh_has_no_body():
from controllers.console import bp as console_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(console_bp)
@@ -627,12 +588,9 @@ def test_console_agent_debug_conversation_refresh_has_no_body(monkeypatch: pytes
assert "AgentDebugConversationRefreshPayload" not in payload["components"]["schemas"]
def test_console_member_invite_documents_bad_request_response(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
def test_console_member_invite_documents_bad_request_response():
from controllers.console import bp as console_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
@@ -77,13 +77,12 @@ class TestPluginRuntimeExecution:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-api-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-api-key",
)
def test_request_preparation(self, plugin_client, mock_config):
"""Test that requests are properly prepared with correct headers and URL."""
@@ -182,13 +181,12 @@ class TestPluginRuntimeSandboxIsolation:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "secure-api-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="secure-api-key",
)
def test_api_key_authentication(self, plugin_client, mock_config):
"""Test that all requests include API key for authentication."""
@@ -272,13 +270,13 @@ class TestPluginRuntimeResourceLimits:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration with timeout."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
patch("core.plugin.impl.base.plugin_daemon_request_timeout", httpx.Timeout(30.0)),
):
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
with patch("core.plugin.impl.base.plugin_daemon_request_timeout", httpx.Timeout(30.0)):
yield
def test_timeout_configuration_applied(self, plugin_client, mock_config):
@@ -346,13 +344,12 @@ class TestPluginRuntimeErrorHandling:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_plugin_invoke_rate_limit_error(self, plugin_client, mock_config):
"""Test handling of rate limit errors during plugin invocation."""
@@ -605,13 +602,12 @@ class TestPluginRuntimeCommunication:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_request_response_communication(self, plugin_client, mock_config):
"""Test basic request/response communication pattern."""
@@ -811,13 +807,12 @@ class TestPluginToolManagerIntegration:
return PluginToolManager()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_tool_invocation_success(self, tool_manager, mock_config):
"""Test successful tool invocation."""
@@ -938,13 +933,12 @@ class TestPluginInstallerIntegration:
return PluginInstaller()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_list_plugins_success(self, installer, mock_config):
"""Test successful plugin listing."""
@@ -1012,13 +1006,12 @@ class TestPluginRuntimeEdgeCases:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_malformed_json_response(self, plugin_client, mock_config):
"""Test handling of malformed JSON responses."""
@@ -1174,13 +1167,12 @@ class TestPluginRuntimeAdvancedScenarios:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_multiple_sequential_requests(self, plugin_client, mock_config):
"""Test multiple sequential requests to the same endpoint."""
@@ -1360,13 +1352,12 @@ class TestPluginRuntimeSecurityAndValidation:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "secure-key-123"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="secure-key-123",
)
def test_api_key_header_always_present(self, plugin_client, mock_config):
"""Test that API key header is always included in requests."""
@@ -1480,13 +1471,12 @@ class TestPluginRuntimePerformanceScenarios:
return BasePluginClient()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_high_volume_streaming(self, plugin_client, mock_config):
"""Test streaming with high volume of chunks."""
@@ -1598,13 +1588,12 @@ class TestPluginToolManagerAdvanced:
return PluginToolManager()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_tool_invocation_with_complex_parameters(self, tool_manager, mock_config):
"""Test tool invocation with complex parameter structures."""
@@ -1750,13 +1739,12 @@ class TestPluginInstallerAdvanced:
return PluginInstaller()
@pytest.fixture
def mock_config(self):
def mock_config(self, config_overrides):
"""Mock plugin daemon configuration."""
with (
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"),
patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"),
):
yield
config_overrides(
PLUGIN_DAEMON_URL="http://127.0.0.1:5002",
PLUGIN_DAEMON_KEY="test-key",
)
def test_upload_plugin_package_success(self, installer, mock_config):
"""Test successful plugin package upload."""
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Literal
from urllib.parse import parse_qs, urlparse
@@ -18,6 +19,16 @@ from core.tools.signature import (
)
@pytest.fixture(autouse=True)
def _signature_config(config_overrides: Callable[..., None]) -> None:
config_overrides(
SECRET_KEY="unit-secret",
FILES_URL="https://files.example.com",
INTERNAL_FILES_URL="https://internal.example.com",
FILES_ACCESS_TIMEOUT=120,
)
def test_bind_file_uri_uses_selected_base_and_preserves_remote_url() -> None:
uri = "/files/tools/tool-file-id.png?sign=1"
@@ -31,7 +42,6 @@ def test_bind_file_uri_uses_selected_base_and_preserves_remote_url() -> None:
def test_sign_tool_file_uri_has_no_origin(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x08" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
uri = sign_tool_file_uri("tool-file-id", ".png")
parsed = urlparse(uri)
@@ -45,10 +55,6 @@ def test_sign_tool_file_uri_has_no_origin(monkeypatch: pytest.MonkeyPatch) -> No
def test_sign_tool_file_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x01" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 120)
url = sign_tool_file("tool-file-id", ".png", for_external=False)
parsed = urlparse(url)
@@ -66,10 +72,6 @@ def test_sign_tool_file_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) ->
def test_sign_tool_file_for_external_uses_files_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x04" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 120)
url = sign_tool_file("tool-file-id", ".png", for_external=True)
parsed = urlparse(url)
@@ -79,13 +81,12 @@ def test_sign_tool_file_for_external_uses_files_url(monkeypatch: pytest.MonkeyPa
assert parsed.path == "/files/tools/tool-file-id.png"
def test_verify_tool_file_signature_rejects_invalid_sign(monkeypatch: pytest.MonkeyPatch) -> None:
def test_verify_tool_file_signature_rejects_invalid_sign(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(INTERNAL_FILES_URL="", FILES_ACCESS_TIMEOUT=10)
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x02" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 10)
url = sign_tool_file("tool-file-id", ".txt")
parsed = urlparse(url)
@@ -97,13 +98,12 @@ def test_verify_tool_file_signature_rejects_invalid_sign(monkeypatch: pytest.Mon
assert verify_tool_file_signature("tool-file-id", timestamp, nonce, "bad-signature") is False
def test_verify_tool_file_signature_rejects_expired_signature(monkeypatch: pytest.MonkeyPatch) -> None:
def test_verify_tool_file_signature_rejects_expired_signature(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(INTERNAL_FILES_URL="", FILES_ACCESS_TIMEOUT=10)
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x02" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 10)
url = sign_tool_file("tool-file-id", ".txt")
parsed = urlparse(url)
@@ -119,9 +119,6 @@ def test_verify_tool_file_signature_rejects_expired_signature(monkeypatch: pytes
def test_sign_upload_file_preview_url_uses_files_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x03" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com")
url = sign_upload_file_preview_url("upload-id", ".png")
parsed = urlparse(url)
@@ -137,9 +134,6 @@ def test_sign_upload_file_preview_url_uses_files_url(monkeypatch: pytest.MonkeyP
def test_sign_upload_file_preview_url_ignores_internal_files_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x05" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com")
url = sign_upload_file_preview_url("upload-id", ".png")
parsed = urlparse(url)
@@ -152,11 +146,12 @@ def test_sign_upload_file_preview_url_ignores_internal_files_url(monkeypatch: py
assert query["sign"][0]
def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(FILES_ACCESS_TIMEOUT=60)
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x06" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
@@ -198,13 +193,13 @@ def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest
)
def test_plugin_upload_signature_binds_max_size_without_legacy_payload_ambiguity(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
user_from: Literal["account", "end-user"] | None,
forged_nonce_suffix: str,
) -> None:
config_overrides(FILES_ACCESS_TIMEOUT=60)
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x0a" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
@@ -233,11 +228,12 @@ def test_plugin_upload_signature_binds_max_size_without_legacy_payload_ambiguity
assert verify_plugin_file_signature(**forged) is False
def test_plugin_upload_signature_binds_account_user_from(monkeypatch: pytest.MonkeyPatch) -> None:
def test_plugin_upload_signature_binds_account_user_from(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(FILES_ACCESS_TIMEOUT=60)
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x09" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
@@ -263,11 +259,12 @@ def test_plugin_upload_signature_binds_account_user_from(monkeypatch: pytest.Mon
assert verify_plugin_file_signature(**signed) is False
def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: pytest.MonkeyPatch) -> None:
def test_verify_plugin_file_signature_rejects_invalid_signatures(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(FILES_ACCESS_TIMEOUT=30)
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 30)
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
@@ -10,8 +10,6 @@ Test coverage:
- Disabled mode behavior
"""
from unittest.mock import patch
import pytest
from opentelemetry.trace import StatusCode
@@ -21,10 +19,14 @@ from graphon.enums import BuiltinNodeTypes
from graphon.graph_events import GraphRunAbortedEvent
@pytest.fixture(autouse=True)
def _otel_config(config_overrides) -> None:
config_overrides(ENABLE_OTEL=True)
class TestObservabilityLayerInitialization:
"""Test ObservabilityLayer initialization logic."""
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_initialization_when_otel_enabled(self, tracer_provider_with_memory_exporter):
"""Test that layer initializes correctly when OTel is enabled."""
@@ -34,10 +36,10 @@ class TestObservabilityLayerInitialization:
assert BuiltinNodeTypes.TOOL in layer._parsers
assert layer._default_parser is not None
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_true")
def test_initialization_when_instrument_flag_enabled(self, tracer_provider_with_memory_exporter):
def test_initialization_when_instrument_flag_enabled(self, tracer_provider_with_memory_exporter, config_overrides):
"""Test that layer enables when instrument flag is enabled."""
config_overrides(ENABLE_OTEL=False)
layer = ObservabilityLayer()
assert not layer._is_disabled
assert layer._tracer is not None
@@ -48,7 +50,6 @@ class TestObservabilityLayerInitialization:
class TestObservabilityLayerNodeSpanLifecycle:
"""Test node span creation and lifecycle management."""
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_node_span_created_and_ended(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
@@ -65,7 +66,6 @@ class TestObservabilityLayerNodeSpanLifecycle:
assert spans[0].name == mock_llm_node.title
assert spans[0].status.status_code == StatusCode.OK
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_node_error_recorded_in_span(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
@@ -84,7 +84,6 @@ class TestObservabilityLayerNodeSpanLifecycle:
assert len(spans[0].events) > 0
assert any("exception" in event.name.lower() for event in spans[0].events)
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_node_end_without_start_handled_gracefully(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
@@ -102,7 +101,6 @@ class TestObservabilityLayerNodeSpanLifecycle:
class TestObservabilityLayerParserIntegration:
"""Test parser integration for different node types."""
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_default_parser_used_for_regular_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node
@@ -121,7 +119,6 @@ class TestObservabilityLayerParserIntegration:
assert attrs["node.execution_id"] == mock_start_node.execution_id
assert attrs["node.type"] == mock_start_node.node_type
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_tool_parser_used_for_tool_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_tool_node
@@ -140,7 +137,6 @@ class TestObservabilityLayerParserIntegration:
assert attrs["gen_ai.tool.name"] == mock_tool_node.title
assert attrs["gen_ai.tool.type"] == mock_tool_node._node_data.provider_type.value
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_llm_parser_used_for_llm_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node, mock_result_event
@@ -178,7 +174,6 @@ class TestObservabilityLayerParserIntegration:
assert attrs["gen_ai.completion"] == "test completion"
assert attrs["gen_ai.response.finish_reason"] == "stop"
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_retrieval_parser_used_for_retrieval_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_retrieval_node, mock_result_event
@@ -206,7 +201,6 @@ class TestObservabilityLayerParserIntegration:
assert attrs["retrieval.query"] == "test query"
assert "retrieval.document" in attrs
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_result_event_extracts_inputs_and_outputs(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node, mock_result_event
@@ -237,7 +231,6 @@ class TestObservabilityLayerParserIntegration:
class TestObservabilityLayerGraphLifecycle:
"""Test graph lifecycle management."""
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_on_graph_start_clears_contexts(self, tracer_provider_with_memory_exporter, mock_llm_node):
"""Test that on_graph_start clears node contexts."""
@@ -250,7 +243,6 @@ class TestObservabilityLayerGraphLifecycle:
layer.on_graph_start()
assert len(layer._node_contexts) == 0
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_on_graph_end_with_no_unfinished_spans(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
@@ -266,7 +258,6 @@ class TestObservabilityLayerGraphLifecycle:
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_on_graph_end_with_unfinished_spans_logs_warning(
self, tracer_provider_with_memory_exporter, mock_llm_node, caplog
@@ -283,7 +274,6 @@ class TestObservabilityLayerGraphLifecycle:
assert len(layer._node_contexts) == 0
assert "node spans were not properly ended" in caplog.text
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_graph_aborted_event_records_reason_on_current_span(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node
@@ -308,10 +298,10 @@ class TestObservabilityLayerGraphLifecycle:
class TestObservabilityLayerDisabledMode:
"""Test behavior when layer is disabled."""
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_disabled_mode_skips_node_start(self, memory_span_exporter, mock_start_node):
def test_disabled_mode_skips_node_start(self, memory_span_exporter, mock_start_node, config_overrides):
"""Test that disabled layer doesn't create spans on node start."""
config_overrides(ENABLE_OTEL=False)
layer = ObservabilityLayer()
assert layer._is_disabled
@@ -322,10 +312,10 @@ class TestObservabilityLayerDisabledMode:
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 0
@patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_disabled_mode_skips_node_end(self, memory_span_exporter, mock_llm_node):
def test_disabled_mode_skips_node_end(self, memory_span_exporter, mock_llm_node, config_overrides):
"""Test that disabled layer doesn't process node end."""
config_overrides(ENABLE_OTEL=False)
layer = ObservabilityLayer()
assert layer._is_disabled
@@ -0,0 +1,39 @@
from collections.abc import Callable
from types import SimpleNamespace
from typing import cast
from unittest.mock import patch
from sqlalchemy.orm import Session, sessionmaker
from extensions.logstore.repositories.logstore_workflow_execution_repository import (
LogstoreWorkflowExecutionRepository,
)
from models.account import Account
from models.enums import WorkflowRunTriggeredFrom
def test_repository_uses_typed_logstore_migration_flags(
config_overrides: Callable[..., None],
sqlite_session_factory: sessionmaker[Session],
) -> None:
config_overrides(
LOGSTORE_DUAL_WRITE_ENABLED=True,
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=False,
)
with (
patch("extensions.logstore.repositories.logstore_workflow_execution_repository.AliyunLogStore"),
patch(
"extensions.logstore.repositories.logstore_workflow_execution_repository."
"SQLAlchemyWorkflowExecutionRepository"
),
):
repository = LogstoreWorkflowExecutionRepository(
session_factory=sqlite_session_factory,
tenant_id="tenant-1",
user=cast(Account, SimpleNamespace(id="account-1")),
app_id="app-1",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
assert repository._enable_dual_write is True
assert repository._enable_put_graph_field is False
@@ -1,6 +1,6 @@
from collections.abc import Callable
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session, sessionmaker
from extensions.logstore.repositories.logstore_workflow_node_execution_repository import (
@@ -17,9 +17,9 @@ def _make_account() -> Account:
def test_save_synchronously_writes_sql_when_dual_write_is_disabled(
monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
config_overrides: Callable[..., None], sqlite_session_factory: sessionmaker[Session]
) -> None:
monkeypatch.delenv("LOGSTORE_DUAL_WRITE_ENABLED", raising=False)
config_overrides(LOGSTORE_DUAL_WRITE_ENABLED=False)
with (
patch("extensions.logstore.repositories.logstore_workflow_node_execution_repository.AliyunLogStore"),
patch(
+26 -18
View File
@@ -41,26 +41,32 @@ def test_extract_access_token():
assert extract_webapp_access_token(request) == expected_webapp
def test_real_cookie_name_uses_host_prefix_without_domain(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False)
monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False)
monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", "", raising=False)
def test_real_cookie_name_uses_host_prefix_without_domain(config_overrides):
config_overrides(
CONSOLE_WEB_URL="https://console.example.com",
CONSOLE_API_URL="https://api.example.com",
COOKIE_DOMAIN="",
)
assert token._real_cookie_name("csrf_token") == "__Host-csrf_token"
def test_real_cookie_name_without_host_prefix_when_domain_present(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False)
monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False)
monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False)
def test_real_cookie_name_without_host_prefix_when_domain_present(config_overrides):
config_overrides(
CONSOLE_WEB_URL="https://console.example.com",
CONSOLE_API_URL="https://api.example.com",
COOKIE_DOMAIN=".example.com",
)
assert token._real_cookie_name("csrf_token") == "csrf_token"
def test_set_csrf_cookie_includes_domain_when_configured(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False)
monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False)
monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False)
def test_set_csrf_cookie_includes_domain_when_configured(config_overrides):
config_overrides(
CONSOLE_WEB_URL="https://console.example.com",
CONSOLE_API_URL="https://api.example.com",
COOKIE_DOMAIN=".example.com",
)
response = Response()
request = MagicMock()
@@ -94,12 +100,14 @@ def test_non_whitelisted_path_requires_csrf():
token.check_csrf_token(request, "account-1")
def test_admin_api_key_header_bypasses_csrf_when_console_cookie_is_present(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(token.dify_config, "ADMIN_API_KEY_ENABLE", True)
monkeypatch.setattr(token.dify_config, "ADMIN_API_KEY", "admin-key")
monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "http://console.example.com")
monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "http://api.example.com")
monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", "")
def test_admin_api_key_header_bypasses_csrf_when_console_cookie_is_present(config_overrides):
config_overrides(
ADMIN_API_KEY_ENABLE=True,
ADMIN_API_KEY="admin-key",
CONSOLE_WEB_URL="http://console.example.com",
CONSOLE_API_URL="http://api.example.com",
COOKIE_DOMAIN="",
)
request = cast(
Request,
MockRequest(