Merge branch 'main' into feat/dify-builder

This commit is contained in:
twwu committed 2026-09-17 17:01:34 +08:00
commit 782688563d
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(
@@ -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(