mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
Merge branch 'main' into feat/dify-builder
This commit is contained in:
266 files changed
+9891
-3597
No files matched your search
@@ -146,7 +146,7 @@ class TestAppImportApi:
|
||||
sqlite_app_engine: Engine,
|
||||
transaction_events: TransactionEvents,
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
method = unwrap(api._import_dsl)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
app_id = _install_persisting_service_result(
|
||||
@@ -172,7 +172,7 @@ class TestAppImportApi:
|
||||
sqlite_app_engine: Engine,
|
||||
transaction_events: TransactionEvents,
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
method = unwrap(api._import_dsl)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
app_id = _install_persisting_service_result(
|
||||
@@ -198,7 +198,7 @@ class TestAppImportApi:
|
||||
sqlite_app_engine: Engine,
|
||||
transaction_events: TransactionEvents,
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
method = unwrap(api._import_dsl)
|
||||
|
||||
_install_features(monkeypatch, enabled=True)
|
||||
app_id = _install_persisting_service_result(
|
||||
@@ -227,7 +227,7 @@ class TestAppImportApi:
|
||||
sqlite_app_engine: Engine,
|
||||
transaction_events: TransactionEvents,
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
method = _unwrap(api._import_dsl)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
monkeypatch.setattr(
|
||||
@@ -263,7 +263,7 @@ class TestAppImportApi:
|
||||
sqlite_app_engine: Engine,
|
||||
transaction_events: TransactionEvents,
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
method = _unwrap(api._import_dsl)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console import console_ns
|
||||
@@ -156,7 +156,7 @@ class TestAppMCPServerController:
|
||||
|
||||
assert response == {}
|
||||
|
||||
def test_post_returns_201(self, sqlite_session: Session) -> None:
|
||||
def test_post_returns_201(self, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
api = AppMCPServerController()
|
||||
method = unwrap(api.post)
|
||||
payload = {"parameters": {"timeout": 30}}
|
||||
@@ -166,7 +166,7 @@ class TestAppMCPServerController:
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.app.mcp_server.db.session", sqlite_session),
|
||||
patch("controllers.console.app.mcp_server.db.session", scoped_session(sqlite_session_factory)),
|
||||
patch("controllers.console.app.mcp_server.AppMCPServer.generate_server_code", return_value="server-code"),
|
||||
):
|
||||
response, status_code = method(
|
||||
@@ -257,7 +257,9 @@ class TestAppMCPServerController:
|
||||
|
||||
|
||||
class TestAppMCPServerRefreshController:
|
||||
def test_post_refreshes_server_bound_to_app_and_tenant(self, sqlite_session: Session) -> None:
|
||||
def test_post_refreshes_server_bound_to_app_and_tenant(
|
||||
self, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
api = AppMCPServerRefreshController()
|
||||
method = unwrap(api.post)
|
||||
server = _server(server_code="old-code")
|
||||
@@ -270,7 +272,7 @@ class TestAppMCPServerRefreshController:
|
||||
sqlite_session.commit()
|
||||
|
||||
with (
|
||||
patch("controllers.console.app.mcp_server.db.session", sqlite_session),
|
||||
patch("controllers.console.app.mcp_server.db.session", scoped_session(sqlite_session_factory)),
|
||||
patch("controllers.console.app.mcp_server.AppMCPServer.generate_server_code", return_value="new-code"),
|
||||
):
|
||||
response = method(api, "tenant-1", app_model=_app())
|
||||
|
||||
@@ -4,7 +4,6 @@ import importlib
|
||||
import json
|
||||
import os
|
||||
from inspect import unwrap
|
||||
from typing import Never
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -21,13 +20,13 @@ from models.model import App, AppMode, AppModelConfig
|
||||
app_wraps_module = importlib.import_module("controllers.console.app.wraps")
|
||||
|
||||
|
||||
def _poison_implicit_app_config_properties(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fail(_app: App) -> Never:
|
||||
raise AssertionError("implicit App model-config property was accessed")
|
||||
|
||||
# `App.is_agent` used to be poisoned here too. It no longer exists as an
|
||||
# implicit property, so its absence enforces the same thing outright.
|
||||
monkeypatch.setattr(App, "app_model_config", property(fail))
|
||||
def _assert_no_implicit_app_config_properties() -> None:
|
||||
# `App.is_agent` and `App.app_model_config` used to be poisoned here with a
|
||||
# raising property. Neither exists as an implicit accessor any more, so their
|
||||
# absence enforces the same thing outright — assert it instead of
|
||||
# monkeypatching an attribute that is gone.
|
||||
assert not hasattr(App, "is_agent")
|
||||
assert not hasattr(App, "app_model_config")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("app_mode", [AppMode.CHAT, AppMode.COMPLETION])
|
||||
@@ -54,7 +53,7 @@ def test_post_updates_non_agent_model_config_without_implicit_properties(
|
||||
original_config.agent_mode = None
|
||||
sqlite_session.add(original_config)
|
||||
sqlite_session.commit()
|
||||
_poison_implicit_app_config_properties(monkeypatch)
|
||||
_assert_no_implicit_app_config_properties()
|
||||
monkeypatch.setattr(
|
||||
model_config_module.AppModelConfigService,
|
||||
"validate_configuration",
|
||||
@@ -184,7 +183,7 @@ def test_post_encrypts_agent_tool_parameters(
|
||||
updated_by=None,
|
||||
updated_at=None,
|
||||
)
|
||||
_poison_implicit_app_config_properties(monkeypatch)
|
||||
_assert_no_implicit_app_config_properties()
|
||||
|
||||
original_config = AppModelConfig(app_id="app-1", created_by="u1", updated_by="u1")
|
||||
original_config.id = "config-0"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import io
|
||||
from collections.abc import Callable
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import BadRequest, Forbidden
|
||||
|
||||
from controllers.console.app import app as app_module
|
||||
from controllers.console.app import app_import as import_module
|
||||
from core.rbac import RBACPermission
|
||||
from models.account import Account, Tenant
|
||||
from models.model import App, AppMode
|
||||
from services.agent.errors import InvalidRosterAgentPackageError
|
||||
from services.agent.roster_package_importer import RosterAgentPackageImportResult
|
||||
from services.entities.dsl_entities import DslImportWarning
|
||||
|
||||
|
||||
def _account() -> Account:
|
||||
account = Account(name="Importer", email="importer@example.com")
|
||||
account._current_tenant = Tenant(name="Workspace")
|
||||
account._current_tenant.id = "tenant-1"
|
||||
return account
|
||||
|
||||
|
||||
def test_existing_import_route_dispatches_json_without_changing_payload(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
api = import_module.AppImportApi()
|
||||
import_dsl = Mock(return_value=({"status": "completed"}, 200))
|
||||
monkeypatch.setattr(api, "_import_dsl", import_dsl)
|
||||
account = _account()
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/imports", method="POST", json={"mode": "yaml-content", "yaml_content": "app: {}"}
|
||||
):
|
||||
result = unwrap(api.post)(api, account)
|
||||
assert result == ({"status": "completed"}, 200)
|
||||
assert import_dsl.call_args.args == (
|
||||
import_module.AppImportPayload(mode="yaml-content", yaml_content="app: {}"),
|
||||
account,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_warning", [False, True])
|
||||
def test_existing_import_route_accepts_package_and_preserves_import_response(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], has_warning: bool
|
||||
) -> None:
|
||||
config_overrides(RBAC_ENABLED=False)
|
||||
account = _account()
|
||||
warnings = (
|
||||
[DslImportWarning(code="agent_skill_missing", path="skills.s_000001", message="Missing Skill")]
|
||||
if has_warning
|
||||
else []
|
||||
)
|
||||
import_package = Mock(
|
||||
return_value=RosterAgentPackageImportResult(app_id="app-1", agent_id="agent-1", warnings=warnings)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
import_module, "RosterAgentPackageImporter", lambda: SimpleNamespace(import_package=import_package)
|
||||
)
|
||||
api = import_module.AppImportApi()
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/imports", method="POST", data={"file": (io.BytesIO(b"package"), "agent.ifpkg")}
|
||||
):
|
||||
data, status = unwrap(api.post)(api, account)
|
||||
assert import_package.call_args.kwargs["source"].read() == b"package"
|
||||
assert status == 200
|
||||
assert data["app_id"] == "app-1"
|
||||
assert data["app_mode"] == "agent"
|
||||
assert data["status"] == ("completed-with-warnings" if has_warning else "completed")
|
||||
assert data["warnings"] == [warning.model_dump(mode="json") for warning in warnings]
|
||||
assert data["id"]
|
||||
assert import_package.call_args.kwargs["tenant_id"] == "tenant-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("denied", [RBACPermission.AGENT_CREATE, RBACPermission.AGENT_IMPORT_EXPORT_DSL])
|
||||
def test_package_import_checks_agent_permissions_before_reading_payload(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], denied: RBACPermission
|
||||
) -> None:
|
||||
config_overrides(RBAC_ENABLED=True)
|
||||
account = _account()
|
||||
monkeypatch.setattr("controllers.common.wraps.current_account_with_tenant", lambda: (account, "tenant-1"))
|
||||
scenes = []
|
||||
|
||||
def check(*_args, **kwargs: object) -> bool:
|
||||
scenes.append(kwargs["scene"])
|
||||
return kwargs["scene"] != denied
|
||||
|
||||
monkeypatch.setattr("controllers.common.rbac.checks.RBACService.CheckAccess.check", check)
|
||||
importer = Mock()
|
||||
monkeypatch.setattr(import_module, "RosterAgentPackageImporter", importer)
|
||||
api = import_module.AppImportApi()
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/imports", method="POST", data={"file": (io.BytesIO(b"package"), "agent.ifpkg")}
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
unwrap(api.post)(api, account)
|
||||
assert denied in scenes
|
||||
assert RBACPermission.APP_IMPORT_EXPORT_DSL not in scenes
|
||||
importer.assert_not_called()
|
||||
|
||||
|
||||
def test_package_import_rejects_overwrite(app: Flask, config_overrides: Callable[..., None]) -> None:
|
||||
config_overrides(RBAC_ENABLED=False)
|
||||
api = import_module.AppImportApi()
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/imports",
|
||||
method="POST",
|
||||
data={"app_id": "existing", "file": (io.BytesIO(b"package"), "agent.ifpkg")},
|
||||
):
|
||||
with pytest.raises(InvalidRosterAgentPackageError, match="overwriting"):
|
||||
unwrap(api.post)(api, _account())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", [{}, {"format": "ifpkg"}])
|
||||
def test_existing_export_route_returns_ifpkg_for_agent(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, query: dict[str, str]
|
||||
) -> None:
|
||||
model = App(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
||||
monkeypatch.setattr(App, "bound_agent_id_with_session", lambda _self, **_kwargs: "agent-1")
|
||||
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=lambda: object()))
|
||||
archive = io.BytesIO(b"package")
|
||||
close = Mock()
|
||||
export = Mock(return_value=SimpleNamespace(archive=archive, filename="agent.ifpkg", close=close))
|
||||
monkeypatch.setattr(app_module, "RosterAgentPackageExporter", lambda: SimpleNamespace(export=export))
|
||||
with app.test_request_context("/console/api/apps/app-1/export", query_string=query):
|
||||
response = unwrap(app_module.AppExportApi.get)(
|
||||
app_module.AppExportApi(), app_module.AppExportQuery.model_validate(query), model
|
||||
)
|
||||
response.direct_passthrough = False
|
||||
assert response.get_data() == b"package"
|
||||
assert response.mimetype == "application/zip"
|
||||
assert "agent.ifpkg" in response.headers["Content-Disposition"]
|
||||
response.close()
|
||||
export.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
|
||||
close.assert_called_once_with()
|
||||
|
||||
|
||||
def test_ifpkg_export_rejects_non_agent_apps() -> None:
|
||||
model = App(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW)
|
||||
with pytest.raises(BadRequest):
|
||||
unwrap(app_module.AppExportApi.get)(app_module.AppExportApi(), app_module.AppExportQuery(format="ifpkg"), model)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "query"),
|
||||
[(AppMode.AGENT, {"format": "yaml"}), (AppMode.WORKFLOW, {}), (AppMode.WORKFLOW, {"format": "yaml"})],
|
||||
)
|
||||
def test_yaml_export_remains_available(monkeypatch: pytest.MonkeyPatch, mode: AppMode, query: dict[str, str]) -> None:
|
||||
model = App(id="app-1", tenant_id="tenant-1", mode=mode)
|
||||
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=lambda: object()))
|
||||
monkeypatch.setattr(app_module.AppDslService, "export_dsl", lambda **_kwargs: "app: {}")
|
||||
assert unwrap(app_module.AppExportApi.get)(
|
||||
app_module.AppExportApi(), app_module.AppExportQuery.model_validate(query), model
|
||||
) == {"data": "app: {}"}
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
|
||||
@@ -74,7 +75,7 @@ class TestActivateCheckApi:
|
||||
return_value=_services(activation_service),
|
||||
),
|
||||
):
|
||||
response = unwrap(ActivateCheckApi.get)(ActivateCheckApi())
|
||||
response = ActivateCheckApi().get()
|
||||
|
||||
assert response == {
|
||||
"is_valid": True,
|
||||
@@ -104,10 +105,24 @@ class TestActivateCheckApi:
|
||||
return_value=_services(activation_service),
|
||||
),
|
||||
):
|
||||
response = unwrap(ActivateCheckApi.get)(ActivateCheckApi())
|
||||
response = ActivateCheckApi().get()
|
||||
|
||||
assert response == {"is_valid": False}
|
||||
|
||||
def test_rejects_request_without_token(self, app: Flask, activation_service: Mock) -> None:
|
||||
"""`token` is required, so a tokenless query must not reach the application service."""
|
||||
with (
|
||||
app.test_request_context("/activate/check?workspace_id=workspace-123"),
|
||||
patch(
|
||||
"controllers.console.auth.activate.application_services",
|
||||
return_value=_services(activation_service),
|
||||
),
|
||||
pytest.raises(UnprocessableEntity),
|
||||
):
|
||||
ActivateCheckApi().get()
|
||||
|
||||
activation_service.check.assert_not_called()
|
||||
|
||||
|
||||
class TestActivateApi:
|
||||
def test_passes_parsed_command_to_application_service(
|
||||
|
||||
+7
-2
@@ -187,9 +187,14 @@ def test_variable_collection_get_raises_when_draft_workflow_missing(
|
||||
api = module.SnippetWorkflowVariableCollectionApi()
|
||||
handler = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/?page=1&limit=20"):
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(module.DraftWorkflowNotExist):
|
||||
handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
|
||||
handler(
|
||||
api,
|
||||
module.WorkflowDraftVariableListQuery(page=1, limit=20),
|
||||
_make_account(),
|
||||
snippet=SimpleNamespace(id="snippet-1"),
|
||||
)
|
||||
|
||||
|
||||
def test_node_variable_collection_get_lists_persisted_node_variables(
|
||||
|
||||
Reference in new issue
Block a user