diff --git a/api/.importlinter b/api/.importlinter
index 4f3726960e8..875b8237054 100644
--- a/api/.importlinter
+++ b/api/.importlinter
@@ -589,6 +589,7 @@ name = App preview queries are framework and persistence neutral
type = forbidden
source_modules =
services.app_preview_query_service
+ services.app_preview_details_service
forbidden_modules =
controllers
extensions
diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py
index 442f620bdbd..ca37bd6bce9 100644
--- a/api/controllers/console/__init__.py
+++ b/api/controllers/console/__init__.py
@@ -1,5 +1,7 @@
+from collections import OrderedDict
from http import HTTPStatus
from importlib import import_module
+from typing import cast
from flask import Blueprint, current_app, got_request_exception
from flask_restx import Namespace
@@ -29,6 +31,11 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError)
}, status.value
+# Flask-RESTX dispatches in registration order; the shared Exception handler
+# must not shadow this Console-specific error mapping.
+cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(ActiveWorkspaceRequiredError, last=False)
+
+
console_ns = Namespace("console", description="Console management API operations", path="/")
RESOURCE_MODULES = (
diff --git a/api/controllers/console/app/wraps.py b/api/controllers/console/app/wraps.py
index 2c8f66bc053..badb69ce58a 100644
--- a/api/controllers/console/app/wraps.py
+++ b/api/controllers/console/app/wraps.py
@@ -1,9 +1,8 @@
"""Controller decorators for console app resources.
`get_app_model` still supports legacy handlers backed by Flask-SQLAlchemy's
-scoped session. Preview handlers compose `get_previewable_app_model` under
-`controllers.common.session.with_session`; preview admission finishes before
-the request Session loads the accepted App.
+scoped session. Public catalog previews use `app.preview_admission` and
+detached application queries.
"""
from collections.abc import Callable
@@ -15,16 +14,13 @@ from sqlalchemy.orm import Session, scoped_session
from controllers.common.session import with_session
from controllers.console.app.error import AppNotFoundError
-from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode
from models.agent import AgentScope
-from services.app_service import AppService
__all__ = [
"get_app_model",
- "get_previewable_app_model",
"with_session",
]
@@ -56,13 +52,6 @@ def _load_app_model_from_scoped_session(app_id: str) -> App | None:
return app_model
-def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
- """Load a normal App after preview admission completes outside the request Session."""
- if not application_services().recommended_app_queries.is_previewable(app_id):
- return None
- return AppService.get_normal_app_by_id(app_id, session)
-
-
def _get_injected_session(args: tuple[object, ...]) -> Session | None:
"""Return the request session inserted by `with_session`, if this handler has been migrated."""
if len(args) < 2:
@@ -148,75 +137,3 @@ def get_app_model[**P, R](
return decorator
else:
return decorator(view)
-
-
-@overload
-def get_previewable_app_model[**P, R](
- view: Callable[P, R],
- *,
- mode: AppMode | list[AppMode] | None = None,
-) -> Callable[P, R]: ...
-
-
-@overload
-def get_previewable_app_model[**P, R](
- view: None = None,
- *,
- mode: AppMode | list[AppMode] | None = None,
-) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
-
-
-def get_previewable_app_model[**P, R](
- view: Callable[P, R] | None = None,
- *,
- mode: AppMode | list[AppMode] | None = None,
-) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
- """Inject an App authorized for read-only template preview.
-
- Preview reads accept either an explicit TrialApp registration or membership
- in the recommended catalog. This does not grant trial execution, which is
- separately protected by TrialAppResource's feature, registration, and quota
- checks.
- """
-
- def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
- @wraps(view_func)
- def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
- if not kwargs.get("app_id"):
- raise ValueError("missing app_id in path parameters")
-
- app_id = kwargs.get("app_id")
- app_id = str(app_id)
-
- del kwargs["app_id"]
-
- session = _get_injected_session(args)
- if session is None:
- raise RuntimeError("get_previewable_app_model requires @with_session")
- app_model = _load_previewable_app_model(session, app_id)
-
- if not app_model:
- raise AppNotFoundError()
-
- app_mode = AppMode.value_of(app_model.mode)
-
- if mode is not None:
- if isinstance(mode, list):
- modes = mode
- else:
- modes = [mode]
-
- if app_mode not in modes:
- mode_values = {m.value for m in modes}
- raise AppNotFoundError(f"App mode is not in the supported list: {mode_values}")
-
- kwargs["app_model"] = app_model
-
- return view_func(*args, **kwargs)
-
- return decorated_view
-
- if view is None:
- return decorator
- else:
- return decorator(view)
diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py
index 94ce9a2c005..e7e02a0b14a 100644
--- a/api/controllers/console/explore/trial.py
+++ b/api/controllers/console/explore/trial.py
@@ -1,5 +1,6 @@
import logging
-from dataclasses import dataclass
+from collections.abc import Mapping
+from dataclasses import asdict
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
@@ -7,10 +8,10 @@ from uuid import UUID
from flask import Response, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
-from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound, Unauthorized
import services
+from configs import dify_config
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
@@ -41,7 +42,6 @@ from controllers.console.app.error import (
UnsupportedAudioTypeError,
)
from controllers.console.app.preview_admission import get_preview_app
-from controllers.console.app.wraps import get_previewable_app_model, with_session
from controllers.console.explore.error import (
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
)
@@ -79,10 +79,10 @@ from graphon.model_runtime.errors.invoke import InvokeError
from graphon.variables import SecretVariable, VariableBase
from libs import helper
from libs.helper import dump_response, to_timestamp, uuid_value
+from libs.url_utils import normalize_api_base_url
from machinery.context import RequestContext
from models import Account
from models.enums import CreatorUserRole
-from models.workflow import Workflow
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import (
@@ -92,7 +92,6 @@ from services.app_preview_query_service import (
AppPreviewUnavailableError,
)
from services.app_ref_service import AppRefService
-from services.app_service import AppResponseView, AppService
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -414,7 +413,12 @@ class TrialWorkflowResponse(ResponseModel):
result: list[Any] = []
for item in value:
- if isinstance(item, SecretVariable):
+ if isinstance(item, Mapping):
+ serialized = dict(item)
+ if serialized.get("value_type") == "secret":
+ serialized["value"] = encrypter.full_mask_token()
+ result.append(serialized)
+ elif isinstance(item, SecretVariable):
serialized = item.model_dump(mode="json")
serialized["value"] = encrypter.full_mask_token()
result.append(serialized)
@@ -427,27 +431,6 @@ class TrialWorkflowResponse(ResponseModel):
return result
-@dataclass(frozen=True)
-class TrialWorkflowResponseSource:
- workflow: Workflow
- session: Session
-
- @property
- def created_by_account(self) -> Account | None:
- return self.workflow.get_created_by_account(session=self.session)
-
- @property
- def updated_by_account(self) -> Account | None:
- return self.workflow.get_updated_by_account(session=self.session)
-
- @property
- def tool_published(self) -> bool:
- return self.workflow.get_tool_published(session=self.session)
-
- def __getattr__(self, name: str) -> Any:
- return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
-
-
register_schema_models(
console_ns,
WorkflowRunRequest,
@@ -849,37 +832,46 @@ class TrialAppParameterApi(Resource):
class AppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
- @with_session(write=False)
- @get_previewable_app_model(None)
- def get(self, session: Session, app_model):
- """Get app detail"""
+ @console_account_admission()
+ @get_preview_app
+ def get(self, app: AppPreviewRef, request_context: RequestContext) -> dict[str, object]:
+ """Get app detail using the viewer's workspace for tool configuration."""
+ try:
+ detail = application_services().app_preview_details.get_detail(
+ app=app,
+ account_id=request_context.account_id,
+ active_workspace_id=request_context.active_workspace_id,
+ )
+ except AppPreviewUnavailableError as error:
+ raise AppNotFoundError() from error
+ except AppDefinitionUnavailableError as error:
+ raise AppUnavailableError() from error
+ except AccountNotFoundError as error:
+ raise Unauthorized("Account no longer exists.") from error
+ except AppPreviewSiteUnavailableError as error:
+ raise AppPreviewSiteUnavailableHttpError(str(error)) from error
- app_service = AppService()
- app_model = app_service.get_app(app_model, session=session)
-
- return TrialAppDetailResponse.model_validate(
- AppResponseView(app_model, session=session),
- from_attributes=True,
- ).model_dump(mode="json")
+ source = asdict(detail)
+ source["api_base_url"] = normalize_api_base_url(dify_config.SERVICE_API_URL or request.host_url.rstrip("/"))
+ source["site"] = {
+ **asdict(detail.site),
+ "app_base_url": dify_config.APP_WEB_URL or request.url_root.rstrip("/"),
+ }
+ return dump_response(TrialAppDetailResponse, source)
class AppWorkflowApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
- @with_session(write=False)
- @get_previewable_app_model(None)
- def get(self, session: Session, app_model):
- """Get workflow detail"""
- if not app_model.workflow_id:
- raise AppUnavailableError()
-
- workflow = app_model.workflow_with_session(session=session)
- if workflow is None:
- raise AppUnavailableError()
-
- return TrialWorkflowResponse.model_validate(
- TrialWorkflowResponseSource(workflow=workflow, session=session),
- from_attributes=True,
- ).model_dump(mode="json")
+ @get_preview_app
+ def get(self, app: AppPreviewRef) -> dict[str, object]:
+ """Get a detached workflow definition after catalog preview admission."""
+ try:
+ workflow = application_services().app_preview_details.get_workflow(app=app)
+ except AppPreviewUnavailableError as error:
+ raise AppNotFoundError() from error
+ except AppDefinitionUnavailableError as error:
+ raise AppUnavailableError() from error
+ return dump_response(TrialWorkflowResponse, workflow)
class DatasetListApi(Resource):
diff --git a/api/core/agent/tool_configuration.py b/api/core/agent/tool_configuration.py
new file mode 100644
index 00000000000..936f5cfca8c
--- /dev/null
+++ b/api/core/agent/tool_configuration.py
@@ -0,0 +1,52 @@
+"""Build agent tool configuration for responses without changing stored config."""
+
+import logging
+from collections.abc import Mapping
+from copy import deepcopy
+from typing import cast
+
+from pydantic import JsonValue, ValidationError
+
+from core.agent.entities import AgentToolEntity
+from core.tools.tool_manager import ToolManager
+from core.tools.utils.configuration import ToolParameterConfigurationManager
+
+logger = logging.getLogger(__name__)
+
+
+def mask_agent_tool_parameters(
+ *, agent_mode: Mapping[str, JsonValue], app_id: str, tenant_id: str, user_id: str
+) -> dict[str, JsonValue]:
+ """Return a response copy, exposing parameters only after masking succeeds."""
+ result = deepcopy(dict(agent_mode))
+ for tool in cast(list[JsonValue], result.get("tools") or []):
+ if not isinstance(tool, dict):
+ continue
+ unmasked_tool = dict(tool)
+ if "tool_parameters" in tool:
+ tool["tool_parameters"] = {}
+ if len(unmasked_tool) <= 3:
+ continue
+ try:
+ agent_tool = AgentToolEntity.model_validate(unmasked_tool)
+ except ValidationError:
+ continue
+ try:
+ tool_runtime = ToolManager.get_agent_tool_runtime(
+ tenant_id=tenant_id, app_id=app_id, agent_tool=agent_tool, user_id=user_id
+ )
+ manager = ToolParameterConfigurationManager(
+ tenant_id=tenant_id,
+ tool_runtime=tool_runtime,
+ provider_name=agent_tool.provider_id,
+ provider_type=agent_tool.provider_type,
+ identity_id=f"AGENT.{app_id}",
+ )
+ if agent_tool.tool_parameters:
+ parameters = manager.decrypt_tool_parameters(agent_tool.tool_parameters)
+ tool["tool_parameters"] = manager.mask_tool_parameters(parameters or {})
+ else:
+ tool["tool_parameters"] = {}
+ except Exception:
+ logger.exception("Failed to mask agent tool parameters for tool %s", agent_tool.tool_name)
+ return result
diff --git a/api/core/workflow/environment_variables.py b/api/core/workflow/environment_variables.py
new file mode 100644
index 00000000000..61bd3f2d27a
--- /dev/null
+++ b/api/core/workflow/environment_variables.py
@@ -0,0 +1,33 @@
+"""Load persisted workflow environment variables without requiring an ORM model."""
+
+import json
+from typing import cast
+
+from core.helper import encrypter
+from core.workflow.llm_environment_variable import LLMEnvironmentVariable
+from factories import variable_factory
+from graphon.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable, VariableBase
+
+
+def load_environment_variables(
+ *, tenant_id: str | None, serialized_variables: str | None
+) -> list[StringVariable | IntegerVariable | FloatVariable | SecretVariable | LLMEnvironmentVariable]:
+ # Use the workflow owner to avoid relying on request user in background threads.
+ if not tenant_id:
+ return []
+
+ variables = cast(dict[str, dict[str, object]], json.loads(serialized_variables or "{}"))
+ results = [variable_factory.build_environment_variable_from_mapping(value) for value in variables.values()]
+
+ def decrypt_func(
+ var: VariableBase,
+ ) -> StringVariable | IntegerVariable | FloatVariable | SecretVariable | LLMEnvironmentVariable:
+ match var:
+ case SecretVariable():
+ return var.model_copy(update={"value": encrypter.decrypt_token(tenant_id=tenant_id, token=var.value)})
+ case StringVariable() | IntegerVariable() | FloatVariable() | LLMEnvironmentVariable():
+ return var
+ case _:
+ raise AssertionError(f"Unexpected variable type for environment variable: {type(var)}")
+
+ return [decrypt_func(var) for var in results]
diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py
index 94291ce7939..7053ed02caf 100644
--- a/api/extensions/ext_application_services.py
+++ b/api/extensions/ext_application_services.py
@@ -137,6 +137,8 @@ from services.account_password_hasher import DefaultAccountPasswordHasher
from services.account_password_service import AccountPasswordService
from services.account_profile_service import AccountProfileService
from services.app_definition_query_service import AppDefinitionQueryService
+from services.app_preview_details_adapters import AppPreviewDetailsRuntime
+from services.app_preview_details_service import AppPreviewDetails
from services.app_preview_query_service import AppPreviewQueryService
from services.app_site_service import AppSiteService
from services.app_statistic_query import AppStatisticQuery
@@ -262,6 +264,7 @@ class ApplicationServices:
accounts: AccountServices
account_activation: AccountActivationService
app_definitions: AppDefinitionQueryService
+ app_preview_details: AppPreviewDetails
app_previews: AppPreviewQueryService
app_sites: AppSiteService
app_statistics: AppStatisticQuery
@@ -436,6 +439,7 @@ def build_application_services(
installation_state = InstallationStateRepository(session_factory=database_client)
data_source_api_key_auth_bindings = SQLAlchemyDataSourceApiKeyAuthBindingRepository(session_factory=database_client)
app_definition_repository = AppDefinitionQueryRepository(session_factory=database_client)
+ app_preview_repository = AppPreviewQueryRepository(session_factory=database_client)
feature_gateway = FeatureServiceGateway()
accounts = SQLAlchemyAccountRepository(session_factory=database_client)
integrations = SQLAlchemyAccountIntegrationRepository(session_factory=database_client)
@@ -626,8 +630,9 @@ def build_application_services(
dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
),
),
+ app_preview_details=AppPreviewDetailsRuntime(details=app_preview_repository),
app_previews=AppPreviewQueryService(
- apps=AppPreviewQueryRepository(session_factory=database_client),
+ apps=app_preview_repository,
is_previewable=recommended_app_queries.is_previewable,
),
app_sites=AppSiteService(
diff --git a/api/models/workflow.py b/api/models/workflow.py
index 2485056dae1..63e324c5833 100644
--- a/api/models/workflow.py
+++ b/api/models/workflow.py
@@ -24,6 +24,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from typing_extensions import deprecated
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
+from core.workflow.environment_variables import load_environment_variables
from core.workflow.human_input_adapter import adapt_node_config_for_graph
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from core.workflow.nodes.human_input.pause_reason import (
@@ -589,36 +590,7 @@ class Workflow(Base): # bug
def environment_variables(
self,
) -> Sequence[StringVariable | IntegerVariable | FloatVariable | SecretVariable | LLMEnvironmentVariable]:
- # Use workflow.tenant_id to avoid relying on request user in background threads
- tenant_id = self.tenant_id
-
- if not tenant_id:
- return []
-
- environment_variables_dict = cast(SerializedWorkflowVariables, json.loads(self._environment_variables or "{}"))
- results = [
- variable_factory.build_environment_variable_from_mapping(v) for v in environment_variables_dict.values()
- ]
-
- # decrypt secret variables value
- def decrypt_func(
- var: VariableBase,
- ) -> StringVariable | IntegerVariable | FloatVariable | SecretVariable | LLMEnvironmentVariable:
- match var:
- case SecretVariable():
- return var.model_copy(
- update={"value": encrypter.decrypt_token(tenant_id=tenant_id, token=var.value)}
- )
- case StringVariable() | IntegerVariable() | FloatVariable() | LLMEnvironmentVariable():
- return var
- case _:
- # Other variable types are not supported for environment variables
- raise AssertionError(f"Unexpected variable type for environment variable: {type(var)}")
-
- decrypted_results: list[
- SecretVariable | StringVariable | IntegerVariable | FloatVariable | LLMEnvironmentVariable
- ] = [decrypt_func(var) for var in results]
- return decrypted_results
+ return load_environment_variables(tenant_id=self.tenant_id, serialized_variables=self._environment_variables)
@environment_variables.setter
def environment_variables(self, value: Sequence[VariableBase]):
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index d4ac9dc5661..756fdecedfd 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -9351,7 +9351,7 @@ Remove one or more tag bindings from a target.
| 200 | Success | **application/json**: [TagResponse](#tagresponse)
|
### [GET] /trial-apps/{app_id}
-**Get app detail**
+**Get app detail using the viewer's workspace for tool configuration**
#### Parameters
@@ -9540,7 +9540,7 @@ Returns the site configuration for the application including theme, icons, and t
| 200 | Success | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
|
### [GET] /trial-apps/{app_id}/workflows
-**Get workflow detail**
+**Get a detached workflow definition after catalog preview admission**
#### Parameters
diff --git a/api/repositories/app_preview_query_repository.py b/api/repositories/app_preview_query_repository.py
index 9a08b236327..0b53dfcd367 100644
--- a/api/repositories/app_preview_query_repository.py
+++ b/api/repositories/app_preview_query_repository.py
@@ -1,24 +1,46 @@
"""Load detached app-preview data without trial or account admission policy."""
-from collections.abc import Sequence
-from typing import override
+from collections.abc import Mapping, Sequence
+from datetime import datetime
+from typing import cast, override
+from pydantic import JsonValue
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
-from models.account import Tenant
+from core.tools.entities.tool_entities import ToolProviderType
+from core.tools.utils.uuid_utils import is_valid_uuid
+from models.account import Account, Tenant
from models.dataset import Dataset
-from models.model import App, Site
+from models.model import App, AppModelConfig, Site, load_annotation_reply_config
+from models.tools import ApiToolProvider
+from models.workflow import Workflow
from repositories.app_definition_query_repository import map_site_configuration
+from services.account_errors import AccountNotFoundError
+from services.app_definition_query_service import AppDefinitionUnavailableError
+from services.app_preview_details_service import (
+ AppPreviewAccount,
+ AppPreviewAudit,
+ AppPreviewDetail,
+ AppPreviewDetailRecord,
+ AppPreviewDetailSite,
+ AppPreviewDetailsQuery,
+ AppPreviewObject,
+ AppPreviewTag,
+ AppPreviewWorkflow,
+ AppPreviewWorkflowRecord,
+)
from services.app_preview_query_service import (
AppPreviewDataset,
AppPreviewQuery,
AppPreviewRef,
AppPreviewSite,
+ AppPreviewSiteUnavailableError,
+ AppPreviewUnavailableError,
)
-class AppPreviewQueryRepository(AppPreviewQuery):
+class AppPreviewQueryRepository(AppPreviewQuery, AppPreviewDetailsQuery):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@@ -77,3 +99,225 @@ class AppPreviewQueryRepository(AppPreviewQuery):
)
for dataset in datasets
)
+
+ @override
+ def get_detail(self, *, app: AppPreviewRef, account_id: str) -> AppPreviewDetailRecord:
+ with self._session_factory(expire_on_commit=False) as session:
+ app_model = self._get_app(session=session, app=app)
+ account = session.get(Account, account_id)
+ if account is None:
+ raise AccountNotFoundError(f"Account {account_id} no longer exists")
+ model_config = self._get_model_config(session=session, app=app_model)
+ workflow = self._get_workflow(session=session, app=app_model)
+ agent_mode = model_config.agent_mode_dict if model_config is not None else None
+ tools = tuple(cast(Sequence[AppPreviewObject], agent_mode.get("tools", []))) if agent_mode else ()
+ existing_api_provider_ids = self._get_existing_api_provider_ids(
+ session=session, tenant_id=app.tenant_id, tools=tools
+ )
+ configuration = self._map_model_config(session=session, model_config=model_config)
+ site = session.scalar(select(Site).where(Site.app_id == app.app_id).limit(1))
+ if site is None:
+ raise AppPreviewSiteUnavailableError(f"Site for app {app.app_id} is unavailable")
+
+ detail = AppPreviewDetail(
+ id=app_model.id,
+ name=app_model.name,
+ description=app_model.description,
+ mode=app_model.mode,
+ icon_type=app_model.icon_type.value if app_model.icon_type is not None else None,
+ icon=app_model.icon,
+ icon_background=app_model.icon_background,
+ enable_site=app_model.enable_site,
+ enable_api=app_model.enable_api,
+ model_config=configuration,
+ workflow=self._map_workflow_summary(workflow),
+ use_icon_as_answer_icon=app_model.use_icon_as_answer_icon,
+ max_active_requests=app_model.max_active_requests,
+ created_by=app_model.created_by,
+ created_at=app_model.created_at,
+ updated_by=app_model.updated_by,
+ updated_at=app_model.updated_at,
+ deleted_tools=(),
+ tags=tuple(
+ AppPreviewTag(id=tag.id, name=tag.name, type=tag.type)
+ for tag in app_model.tags_with_session(session=session)
+ ),
+ site=self._map_site(site),
+ )
+
+ return AppPreviewDetailRecord(detail=detail, existing_api_provider_ids=existing_api_provider_ids)
+
+ @override
+ def get_workflow(self, *, app: AppPreviewRef) -> AppPreviewWorkflowRecord:
+ with self._session_factory() as session:
+ app_model = self._get_app(session=session, app=app)
+ workflow = self._get_workflow(session=session, app=app_model)
+ if workflow is None:
+ raise AppDefinitionUnavailableError(f"Workflow for app {app.app_id} is unavailable")
+ created_by = self._map_account(workflow.get_created_by_account(session=session))
+ updated_by = self._map_account(workflow.get_updated_by_account(session=session))
+ tool_published = workflow.get_tool_published(session=session)
+
+ # Legacy feature normalization mutates the model property. Keep that
+ # response-only conversion on the detached object after all queries finish.
+ detail = AppPreviewWorkflow(
+ id=workflow.id,
+ graph=cast(AppPreviewObject, workflow.graph_dict),
+ features=cast(AppPreviewObject, workflow.features_dict),
+ hash=workflow.unique_hash,
+ version=workflow.version,
+ marked_name=workflow.marked_name,
+ marked_comment=workflow.marked_comment,
+ created_by=created_by,
+ created_at=workflow.created_at,
+ updated_by=updated_by,
+ updated_at=workflow.updated_at,
+ tool_published=tool_published,
+ environment_variables=(),
+ conversation_variables=tuple(
+ cast(
+ AppPreviewObject,
+ {**variable.model_dump(mode="json"), "value_type": str(variable.value_type.exposed_type())},
+ )
+ for variable in workflow.conversation_variables
+ ),
+ rag_pipeline_variables=tuple(
+ cast(AppPreviewObject, variable) for variable in workflow.rag_pipeline_variables
+ ),
+ )
+ return AppPreviewWorkflowRecord(
+ workflow=detail,
+ tenant_id=workflow.tenant_id,
+ environment_variables_json=workflow._environment_variables,
+ )
+
+ @staticmethod
+ def _get_existing_api_provider_ids(
+ *, session: Session, tenant_id: str, tools: Sequence[AppPreviewObject]
+ ) -> frozenset[str]:
+ provider_ids = {
+ provider_id
+ for tool in tools
+ if len(tool) >= 4
+ and tool.get("provider_type") == ToolProviderType.API
+ and isinstance(provider_id := tool.get("provider_id"), str)
+ and is_valid_uuid(provider_id)
+ }
+ if not provider_ids:
+ return frozenset()
+ return frozenset(
+ session.scalars(
+ select(ApiToolProvider.id).where(
+ ApiToolProvider.tenant_id == tenant_id, ApiToolProvider.id.in_(provider_ids)
+ )
+ )
+ )
+
+ @staticmethod
+ def _get_app(*, session: Session, app: AppPreviewRef) -> App:
+ app_model = session.scalar(
+ select(App).where(App.id == app.app_id, App.tenant_id == app.tenant_id, App.status == "normal")
+ )
+ if app_model is None:
+ raise AppPreviewUnavailableError(f"App {app.app_id} is no longer available in tenant {app.tenant_id}")
+ return app_model
+
+ @staticmethod
+ def _get_model_config(*, session: Session, app: App) -> AppModelConfig | None:
+ if app.app_model_config_id is None:
+ return None
+ config = session.scalar(
+ select(AppModelConfig).where(
+ AppModelConfig.id == app.app_model_config_id,
+ AppModelConfig.app_id == app.id,
+ )
+ )
+ if (
+ config is None
+ and session.scalar(select(AppModelConfig.id).where(AppModelConfig.id == app.app_model_config_id))
+ is not None
+ ):
+ raise AppDefinitionUnavailableError(
+ f"Model config {app.app_model_config_id} does not belong to app {app.id}"
+ )
+ return config
+
+ @staticmethod
+ def _get_workflow(*, session: Session, app: App) -> Workflow | None:
+ if app.workflow_id is None:
+ return None
+ workflow = session.scalar(
+ select(Workflow).where(
+ Workflow.id == app.workflow_id,
+ Workflow.app_id == app.id,
+ Workflow.tenant_id == app.tenant_id,
+ )
+ )
+ if workflow is None and session.scalar(select(Workflow.id).where(Workflow.id == app.workflow_id)) is not None:
+ raise AppDefinitionUnavailableError(f"Workflow {app.workflow_id} does not belong to app {app.id}")
+ return workflow
+
+ @staticmethod
+ def _map_model_config(
+ *, session: Session, model_config: AppModelConfig | None
+ ) -> dict[str, JsonValue | datetime] | None:
+ if model_config is None:
+ return None
+ configuration = model_config.to_dict(
+ annotation_reply=load_annotation_reply_config(session, model_config.app_id)
+ )
+ result: dict[str, JsonValue | datetime] = dict(cast(Mapping[str, JsonValue], configuration))
+ result.update(
+ created_by=model_config.created_by,
+ created_at=model_config.created_at,
+ updated_by=model_config.updated_by,
+ updated_at=model_config.updated_at,
+ )
+ return result
+
+ @staticmethod
+ def _map_workflow_summary(workflow: Workflow | None) -> AppPreviewAudit | None:
+ if workflow is None:
+ return None
+ return AppPreviewAudit(
+ id=workflow.id,
+ created_by=workflow.created_by,
+ created_at=workflow.created_at,
+ updated_by=workflow.updated_by,
+ updated_at=workflow.updated_at,
+ )
+
+ @staticmethod
+ def _map_account(account: Account | None) -> AppPreviewAccount | None:
+ if account is None:
+ return None
+ return AppPreviewAccount(id=account.id, name=account.name, email=account.email)
+
+ @staticmethod
+ def _map_site(site: Site) -> AppPreviewDetailSite:
+ return AppPreviewDetailSite(
+ code=site.code,
+ title=site.title,
+ icon_type=site.icon_type.value if site.icon_type is not None else None,
+ icon=site.icon,
+ icon_background=site.icon_background,
+ description=site.description,
+ default_language=site.default_language,
+ chat_color_theme=site.chat_color_theme,
+ chat_color_theme_inverted=site.chat_color_theme_inverted,
+ customize_domain=site.customize_domain,
+ copyright=site.copyright,
+ privacy_policy=site.privacy_policy,
+ input_placeholder=site.input_placeholder,
+ custom_disclaimer=site.custom_disclaimer,
+ customize_token_strategy=site.customize_token_strategy.value
+ if site.customize_token_strategy is not None
+ else None,
+ prompt_public=site.prompt_public,
+ show_workflow_steps=site.show_workflow_steps,
+ use_icon_as_answer_icon=site.use_icon_as_answer_icon,
+ created_by=site.created_by,
+ created_at=site.created_at,
+ updated_by=site.updated_by,
+ updated_at=site.updated_at,
+ )
diff --git a/api/services/app_preview_details_adapters.py b/api/services/app_preview_details_adapters.py
new file mode 100644
index 00000000000..2b15403c7e8
--- /dev/null
+++ b/api/services/app_preview_details_adapters.py
@@ -0,0 +1,123 @@
+"""Enrich detached app and workflow previews through legacy tool and key providers.
+
+The injected queries close their database sessions before this adapter calls tool,
+plugin or credential providers. TODO: Retire the remaining tool bridge when its
+providers have neutral boundaries. Legacy ToolManager still owns its internal
+global-session and credential-refresh behavior; this adapter does not migrate it.
+"""
+
+from collections.abc import Sequence
+from dataclasses import replace
+from typing import cast, override
+
+from core.agent.tool_configuration import mask_agent_tool_parameters
+from core.plugin.plugin_service import PluginService
+from core.tools.entities.tool_entities import ToolProviderType
+from core.tools.tool_manager import ToolManager
+from core.workflow.environment_variables import load_environment_variables
+from core.workflow.llm_environment_variable import dump_environment_variable
+from models.model import AppMode
+from models.provider_ids import GenericProviderID
+from services.app_preview_details_service import (
+ AppPreviewDeletedTool,
+ AppPreviewDetail,
+ AppPreviewDetails,
+ AppPreviewDetailsQuery,
+ AppPreviewObject,
+ AppPreviewWorkflow,
+)
+from services.app_preview_query_service import AppPreviewRef
+
+
+class AppPreviewDetailsRuntime(AppPreviewDetails):
+ def __init__(self, *, details: AppPreviewDetailsQuery) -> None:
+ self._details: AppPreviewDetailsQuery = details
+
+ @override
+ def get_detail(self, *, app: AppPreviewRef, account_id: str, active_workspace_id: str) -> AppPreviewDetail:
+ record = self._details.get_detail(app=app, account_id=account_id)
+ detail = record.detail
+ configuration = detail.model_config
+ agent_mode = cast(AppPreviewObject, configuration["agent_mode"]) if configuration is not None else None
+ tools = tuple(cast(Sequence[AppPreviewObject], agent_mode.get("tools", []))) if agent_mode else ()
+ # Preserve the response compatibility without the legacy model's App.mode
+ # update and commit. A preview query must leave persisted values unchanged.
+ mode = detail.mode
+ if agent_mode and agent_mode.get("enabled", False) and agent_mode.get("strategy") in {"function_call", "react"}:
+ mode = AppMode.AGENT_CHAT
+ if mode == AppMode.AGENT_CHAT and agent_mode is not None and configuration is not None:
+ configuration = {
+ **configuration,
+ "agent_mode": mask_agent_tool_parameters(
+ agent_mode=agent_mode,
+ app_id=app.app_id,
+ tenant_id=active_workspace_id,
+ user_id=account_id,
+ ),
+ }
+ return replace(
+ detail,
+ mode=mode,
+ model_config=configuration,
+ deleted_tools=self._get_deleted_tools(
+ tenant_id=app.tenant_id, tools=tools, existing_api_provider_ids=record.existing_api_provider_ids
+ ),
+ )
+
+ @staticmethod
+ def _get_deleted_tools(
+ *, tenant_id: str, tools: Sequence[AppPreviewObject], existing_api_provider_ids: frozenset[str]
+ ) -> tuple[AppPreviewDeletedTool, ...]:
+ builtin_providers: dict[str, GenericProviderID] = {}
+ for tool in tools:
+ if len(tool) < 4 or tool.get("provider_type") != ToolProviderType.BUILT_IN:
+ continue
+ provider_id = tool.get("provider_id")
+ if not isinstance(provider_id, str) or not provider_id:
+ continue
+ try:
+ ToolManager.get_hardcoded_provider(provider_id)
+ except KeyError:
+ try:
+ builtin_providers[provider_id] = GenericProviderID(provider_id)
+ except ValueError:
+ continue
+
+ missing_builtin_ids: set[str] = set()
+ if builtin_providers:
+ existence = PluginService.check_tools_existence(tenant_id, list(builtin_providers.values()))
+ missing_builtin_ids = {
+ provider_id for provider_id, exists in zip(builtin_providers, existence, strict=True) if not exists
+ }
+
+ deleted: list[AppPreviewDeletedTool] = []
+ for tool in tools:
+ if len(tool) < 4:
+ continue
+ provider_type = tool.get("provider_type")
+ if provider_type not in (ToolProviderType.API, ToolProviderType.BUILT_IN):
+ continue
+ provider_id = tool.get("provider_id")
+ tool_name = tool.get("tool_name")
+ if not isinstance(provider_id, str) or not provider_id or not isinstance(tool_name, str):
+ continue
+ if (provider_type == ToolProviderType.API and provider_id not in existing_api_provider_ids) or (
+ provider_type == ToolProviderType.BUILT_IN and provider_id in missing_builtin_ids
+ ):
+ deleted.append(
+ AppPreviewDeletedTool(type=str(provider_type), tool_name=tool_name, provider_id=provider_id)
+ )
+ return tuple(deleted)
+
+ @override
+ def get_workflow(self, *, app: AppPreviewRef) -> AppPreviewWorkflow:
+ record = self._details.get_workflow(app=app)
+ variables = load_environment_variables(
+ tenant_id=record.tenant_id, serialized_variables=record.environment_variables_json
+ )
+ return replace(
+ record.workflow,
+ environment_variables=tuple(
+ cast(AppPreviewObject, dump_environment_variable(variable, mode="json")) for variable in variables
+ ),
+ )
diff --git a/api/services/app_preview_details_service.py b/api/services/app_preview_details_service.py
new file mode 100644
index 00000000000..d65d39aaa06
--- /dev/null
+++ b/api/services/app_preview_details_service.py
@@ -0,0 +1,141 @@
+"""Detached app and workflow details for an already admitted preview."""
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Protocol
+
+from pydantic import JsonValue
+
+from services.app_preview_query_service import AppPreviewRef
+
+type AppPreviewModelConfig = Mapping[str, JsonValue | datetime]
+type AppPreviewObject = Mapping[str, JsonValue]
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewAudit:
+ id: str
+ created_by: str | None
+ created_at: datetime | None
+ updated_by: str | None
+ updated_at: datetime | None
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewTag:
+ id: str
+ name: str
+ type: str
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewDeletedTool:
+ type: str
+ tool_name: str
+ provider_id: str
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewDetailSite:
+ code: str | None
+ title: str
+ icon_type: str | None
+ icon: str | None
+ icon_background: str | None
+ description: str | None
+ default_language: str
+ chat_color_theme: str | None
+ chat_color_theme_inverted: bool | None
+ customize_domain: str | None
+ copyright: str | None
+ privacy_policy: str | None
+ input_placeholder: str | None
+ custom_disclaimer: str | None
+ customize_token_strategy: str | None
+ prompt_public: bool | None
+ show_workflow_steps: bool | None
+ use_icon_as_answer_icon: bool | None
+ created_by: str | None
+ created_at: datetime | None
+ updated_by: str | None
+ updated_at: datetime | None
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewDetail:
+ id: str
+ name: str
+ description: str | None
+ mode: str
+ icon_type: str | None
+ icon: str | None
+ icon_background: str | None
+ enable_site: bool
+ enable_api: bool
+ model_config: AppPreviewModelConfig | None
+ workflow: AppPreviewAudit | None
+ use_icon_as_answer_icon: bool | None
+ max_active_requests: int | None
+ created_by: str | None
+ created_at: datetime | None
+ updated_by: str | None
+ updated_at: datetime | None
+ deleted_tools: tuple[AppPreviewDeletedTool, ...]
+ tags: tuple[AppPreviewTag, ...]
+ site: AppPreviewDetailSite
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewDetailRecord:
+ """Stored detail values and owner-scoped API providers before tool enrichment."""
+
+ detail: AppPreviewDetail
+ existing_api_provider_ids: frozenset[str]
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewAccount:
+ id: str
+ name: str | None
+ email: str | None
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewWorkflow:
+ id: str
+ graph: AppPreviewObject
+ features: AppPreviewObject
+ hash: str | None
+ version: str | None
+ marked_name: str | None
+ marked_comment: str | None
+ created_by: AppPreviewAccount | None
+ created_at: datetime | None
+ updated_by: AppPreviewAccount | None
+ updated_at: datetime | None
+ tool_published: bool
+ environment_variables: tuple[AppPreviewObject, ...]
+ conversation_variables: tuple[AppPreviewObject, ...]
+ rag_pipeline_variables: tuple[AppPreviewObject, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class AppPreviewWorkflowRecord:
+ """Workflow data with environment variables still in their stored, encrypted form."""
+
+ workflow: AppPreviewWorkflow
+ tenant_id: str
+ environment_variables_json: str | None
+
+
+class AppPreviewDetailsQuery(Protocol):
+ def get_detail(self, *, app: AppPreviewRef, account_id: str) -> AppPreviewDetailRecord: ...
+
+ def get_workflow(self, *, app: AppPreviewRef) -> AppPreviewWorkflowRecord: ...
+
+
+class AppPreviewDetails(Protocol):
+ def get_detail(self, *, app: AppPreviewRef, account_id: str, active_workspace_id: str) -> AppPreviewDetail: ...
+
+ def get_workflow(self, *, app: AppPreviewRef) -> AppPreviewWorkflow: ...
diff --git a/api/services/app_service.py b/api/services/app_service.py
index f829bcc3f24..33a30fb2018 100644
--- a/api/services/app_service.py
+++ b/api/services/app_service.py
@@ -1,24 +1,22 @@
import json
import logging
-from collections.abc import Callable, Sequence
+from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal, NotRequired, TypedDict, cast, override
import sqlalchemy as sa
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, JsonValue
from sqlalchemy import ColumnElement, delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from configs import dify_config
from constants.model_template import default_app_templates
-from core.agent.entities import AgentToolEntity
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
+from core.agent.tool_configuration import mask_agent_tool_parameters
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.model_manager import ModelManager
-from core.tools.tool_manager import ToolManager
-from core.tools.utils.configuration import ToolParameterConfigurationManager
from enums import DeploymentEdition
from events.app_event import app_was_created, app_was_deleted, app_was_updated
from extensions.ext_database import db # noqa: F401
@@ -339,13 +337,6 @@ class AppService:
) -> App | None:
return session.get(App, app_id)
- @staticmethod
- def get_normal_app_by_id(
- app_id: str,
- session: Session,
- ) -> App | None:
- return session.scalar(select(App).where(App.id == app_id, App.status == "normal").limit(1))
-
@staticmethod
def get_visible_app_by_id(
app_id: str,
@@ -764,42 +755,12 @@ class AppService:
model_config = app.app_model_config_with_session(session=session)
if not model_config:
return app
- agent_mode = model_config.agent_mode_dict
- # decrypt agent tool parameters if it's secret-input
- for tool in agent_mode.get("tools") or []:
- if not isinstance(tool, dict) or len(tool.keys()) <= 3:
- continue
- typed_tool = {key: value for key, value in tool.items() if isinstance(key, str)}
- if len(typed_tool) != len(tool):
- continue
- agent_tool_entity = AgentToolEntity.model_validate(typed_tool)
- # get tool
- try:
- tool_runtime = ToolManager.get_agent_tool_runtime(
- tenant_id=current_user.current_tenant_id,
- app_id=app.id,
- agent_tool=agent_tool_entity,
- user_id=current_user.id,
- )
- manager = ToolParameterConfigurationManager(
- tenant_id=current_user.current_tenant_id,
- tool_runtime=tool_runtime,
- provider_name=agent_tool_entity.provider_id,
- provider_type=agent_tool_entity.provider_type,
- identity_id=f"AGENT.{app.id}",
- )
-
- # get decrypted parameters
- if agent_tool_entity.tool_parameters:
- parameters = manager.decrypt_tool_parameters(agent_tool_entity.tool_parameters or {})
- masked_parameter = manager.mask_tool_parameters(parameters or {})
- else:
- masked_parameter = {}
-
- # override tool parameters
- tool["tool_parameters"] = masked_parameter
- except Exception:
- logger.exception("Failed to mask agent tool parameters for tool %s", agent_tool_entity.tool_name)
+ agent_mode = mask_agent_tool_parameters(
+ agent_mode=cast(Mapping[str, JsonValue], model_config.agent_mode_dict),
+ app_id=app.id,
+ tenant_id=current_user.current_tenant_id,
+ user_id=current_user.id,
+ )
# override agent mode
if model_config:
diff --git a/api/tests/unit_tests/controllers/console/app/test_wraps.py b/api/tests/unit_tests/controllers/console/app/test_wraps.py
index e341dc15d84..46f9e137a7b 100644
--- a/api/tests/unit_tests/controllers/console/app/test_wraps.py
+++ b/api/tests/unit_tests/controllers/console/app/test_wraps.py
@@ -1,16 +1,11 @@
from __future__ import annotations
-from contextlib import nullcontext
from inspect import getsource
-from types import SimpleNamespace
-from unittest.mock import MagicMock
from uuid import uuid4
import pytest
-from sqlalchemy import event, text
from sqlalchemy.orm import Session
-from controllers.common import session as session_module
from controllers.common.session import with_session
from controllers.console.app import completion as completion_module
from controllers.console.app import workflow as workflow_module
@@ -58,60 +53,6 @@ def test_get_app_model_rejects_wrong_mode(monkeypatch: pytest.MonkeyPatch, sqlit
handler(app_id=app_model.id)
-def test_load_previewable_app_model_rejects_app_outside_preview_admission(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- session = MagicMock(spec=Session)
- app_loader = MagicMock()
- recommended_app_queries = MagicMock()
- recommended_app_queries.is_previewable.return_value = False
- monkeypatch.setattr(
- wraps_module,
- "application_services",
- lambda: SimpleNamespace(recommended_app_queries=recommended_app_queries),
- )
- monkeypatch.setattr(wraps_module.AppService, "get_normal_app_by_id", app_loader)
-
- assert wraps_module._load_previewable_app_model(session, "app-1") is None
- recommended_app_queries.is_previewable.assert_called_once_with("app-1")
- app_loader.assert_not_called()
-
-
-def test_load_previewable_app_model_rejects_non_normal_app(
- monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
-) -> None:
- app_model = _persist_app(sqlite_session)
- app_id = app_model.id
- sqlite_session.execute(text("UPDATE apps SET status = 'disabled' WHERE id = :app_id"), {"app_id": app_id})
- sqlite_session.commit()
- recommended_app_queries = MagicMock()
- recommended_app_queries.is_previewable.return_value = True
- monkeypatch.setattr(
- wraps_module,
- "application_services",
- lambda: SimpleNamespace(recommended_app_queries=recommended_app_queries),
- )
-
- assert wraps_module._load_previewable_app_model(sqlite_session, app_id) is None
-
-
-def test_get_previewable_app_model_rejects_app_outside_preview_admission(
- monkeypatch: pytest.MonkeyPatch, unbound_session: Session
-) -> None:
- app_loader = MagicMock(return_value=None)
- monkeypatch.setattr(wraps_module, "_load_previewable_app_model", app_loader)
-
- class Handler:
- @wraps_module.get_previewable_app_model
- def get(self, _injected_session, app_model):
- return app_model.id
-
- with pytest.raises(AppNotFoundError):
- Handler().get(unbound_session, app_id="app-1")
-
- app_loader.assert_called_once_with(unbound_session, "app-1")
-
-
def test_get_app_model_requires_app_id() -> None:
@wraps_module.get_app_model
def handler(app_model):
@@ -144,60 +85,6 @@ def test_get_app_model_prefers_injected_session(
assert Handler().get(sqlite_session, app_id=app_model.id) == app_model.id
-def test_preview_admission_precedes_request_session_transaction(
- monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
-) -> None:
- app_model = _persist_app(sqlite_session)
- app_id = app_model.id
- sqlite_session.rollback()
- request_transaction_begins = 0
-
- def record_request_transaction_begin(_session, _transaction, _connection) -> None:
- nonlocal request_transaction_begins
- request_transaction_begins += 1
-
- event.listen(sqlite_session, "after_begin", record_request_transaction_begin)
- recommended_app_queries = MagicMock()
-
- def assert_request_session_has_not_started(_app_id: str) -> bool:
- assert request_transaction_begins == 0
- assert sqlite_session.in_transaction() is False
- return True
-
- recommended_app_queries.is_previewable.side_effect = assert_request_session_has_not_started
- monkeypatch.setattr(
- wraps_module,
- "application_services",
- lambda: SimpleNamespace(recommended_app_queries=recommended_app_queries),
- )
- monkeypatch.setattr(
- wraps_module.db,
- "session",
- SimpleNamespace(scalar=lambda *_args, **_kwargs: pytest.fail("db.session should not be used")),
- )
- monkeypatch.setattr(session_module.session_factory, "create_session", lambda: nullcontext(sqlite_session))
-
- class Handler:
- @with_session(write=False)
- @wraps_module.get_previewable_app_model(None)
- def get(self, injected_session, app_model):
- assert injected_session is sqlite_session
- return app_model.id
-
- assert Handler().get(app_id=app_id) == app_id
- recommended_app_queries.is_previewable.assert_called_once_with(app_id)
- assert request_transaction_begins == 1
-
-
-def test_get_previewable_app_model_requires_injected_session() -> None:
- @wraps_module.get_previewable_app_model(None)
- def handler(app_model):
- return app_model.id
-
- with pytest.raises(RuntimeError, match="requires @with_session"):
- handler(app_id="app-1")
-
-
@pytest.mark.parametrize(
"resource",
[
diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py
index 3f0708622a8..8d96a37e5da 100644
--- a/api/tests/unit_tests/controllers/console/explore/test_trial.py
+++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py
@@ -1,5 +1,3 @@
-from datetime import UTC, datetime
-from inspect import getsource, signature
from inspect import unwrap as inspect_unwrap
from io import BytesIO
from types import SimpleNamespace
@@ -8,7 +6,6 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, request
-from sqlalchemy.orm import Session
import controllers.console.explore.trial as module
from controllers.console.app.error import (
@@ -24,14 +21,9 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
-from core.helper import encrypter
-from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from graphon.model_runtime.errors.invoke import InvokeError
-from graphon.variables import SecretVariable, StringVariable
from models import Account
from models.model import App, AppMode
-from models.tools import WorkflowToolProvider
-from models.workflow import Workflow
from services.app_ref_service import AppRef, MessageRef
from services.errors.audio import SpeechToTextDisabledServiceError
from tests.unit_tests.model_factories import make_app
@@ -77,39 +69,6 @@ def test_trial_workflow_uses_trial_scoped_simple_account_model() -> None:
assert module.simple_account_model.__schema__["properties"].keys() >= {"id", "name", "email"}
-@pytest.mark.parametrize(
- "api_type",
- [module.AppApi, module.AppWorkflowApi],
-)
-def test_preview_handlers_use_explicit_read_session(api_type: type) -> None:
- source = getsource(api_type.get)
-
- assert "@with_session(write=False)\n @get_previewable_app_model(None)" in source
- assert tuple(signature(api_type.get).parameters)[:3] == ("self", "session", "app_model")
-
-
-def test_trial_app_detail_serializes_with_explicit_session(
- app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
-) -> None:
- app_model = _app(app_id="app-1", mode=AppMode.CHAT)
- response_view = MagicMock()
- get_app = MagicMock(return_value=app_model)
- build_view = MagicMock(return_value=response_view)
- validated = MagicMock()
- validated.model_dump.return_value = {"id": "app-1"}
- monkeypatch.setattr(module, "AppService", lambda: SimpleNamespace(get_app=get_app))
- monkeypatch.setattr(module, "AppResponseView", build_view)
- monkeypatch.setattr(module.TrialAppDetailResponse, "model_validate", MagicMock(return_value=validated))
-
- with app.test_request_context("/"):
- result = unwrap(module.AppApi.get)(module.AppApi(), unbound_session, app_model)
-
- assert result == {"id": "app-1"}
- get_app.assert_called_once_with(app_model, session=unbound_session)
- build_view.assert_called_once_with(app_model, session=unbound_session)
- module.TrialAppDetailResponse.model_validate.assert_called_once_with(response_view, from_attributes=True)
-
-
class TestTrialChatAudioApi:
def test_success(
self,
@@ -532,109 +491,6 @@ class TestTrialChatTextApi:
)
-class TestAppWorkflowApi:
- def test_uses_injected_session(self, sqlite_session: Session) -> None:
- api = module.AppWorkflowApi()
- method = unwrap(api.get)
- created_by = Account(name="Creator", email="creator@example.com")
- created_by.id = "account-1"
- app_model = _app(app_id="app-1", mode=AppMode.WORKFLOW)
- with patch("models.workflow.encrypter.encrypt_token", return_value="encrypted-secret"):
- workflow = Workflow.new(
- tenant_id=app_model.tenant_id,
- app_id=app_model.id,
- type="workflow",
- version="draft",
- graph='{"nodes": []}',
- features="{}",
- created_by=created_by.id,
- environment_variables=[
- SecretVariable(
- id="env-secret",
- name="api_key",
- value="plaintext-secret",
- ),
- LLMEnvironmentVariable(
- id="env-llm",
- name="shared_model",
- value={"provider": "provider", "name": "model", "mode": "chat"},
- ),
- ],
- conversation_variables=[
- StringVariable(
- id="conversation-variable-1",
- name="topic",
- value="sqlite",
- selector=["conversation", "topic"],
- )
- ],
- rag_pipeline_variables=[],
- )
- workflow.id = "workflow-1"
- workflow.created_at = datetime(2024, 1, 1, tzinfo=UTC)
- workflow.updated_at = datetime(2024, 1, 2, tzinfo=UTC)
- app_model.workflow_id = workflow.id
- tool_provider = WorkflowToolProvider(
- name="trial-workflow",
- label="Trial Workflow",
- icon="icon",
- app_id=app_model.id,
- version="1.0.0",
- user_id=created_by.id,
- tenant_id=app_model.tenant_id,
- description="Trial workflow provider",
- parameter_configuration="[]",
- )
- sqlite_session.add_all([created_by, app_model, workflow, tool_provider])
- sqlite_session.commit()
-
- with patch("models.workflow.encrypter.decrypt_token", return_value="plaintext-secret"):
- result = method(api, sqlite_session, app_model)
-
- assert result == {
- "id": "workflow-1",
- "graph": {"nodes": []},
- "features": {},
- "hash": workflow.unique_hash,
- "version": "draft",
- "marked_name": "",
- "marked_comment": "",
- "created_by": {"id": "account-1", "name": "Creator", "email": "creator@example.com"},
- "created_at": 1704067200,
- "updated_by": None,
- "updated_at": 1704153600,
- "tool_published": True,
- "environment_variables": [
- {
- "value_type": "secret",
- "value": encrypter.full_mask_token(),
- "id": "env-secret",
- "name": "api_key",
- "description": "",
- "selector": ["env", "api_key"],
- },
- {
- "value_type": "llm",
- "value": {"provider": "provider", "name": "model", "mode": "chat"},
- "id": "env-llm",
- "name": "shared_model",
- "description": "",
- "selector": ["env", "shared_model"],
- },
- ],
- "conversation_variables": [
- {
- "id": "conversation-variable-1",
- "name": "topic",
- "value_type": "string",
- "value": "sqlite",
- "description": "",
- }
- ],
- "rag_pipeline_variables": [],
- }
-
-
class TestTrialChatAudioApiExceptionHandlers:
def test_provider_not_init(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatAudioApi()
diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial_preview_details.py b/api/tests/unit_tests/controllers/console/explore/test_trial_preview_details.py
new file mode 100644
index 00000000000..60fd3fbb24c
--- /dev/null
+++ b/api/tests/unit_tests/controllers/console/explore/test_trial_preview_details.py
@@ -0,0 +1,803 @@
+"""Preview detail contracts through real HTTP, services, and SQLite queries."""
+
+import json
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass, field
+from datetime import datetime
+from types import SimpleNamespace
+from typing import Literal, cast, override
+from uuid import uuid4
+
+import pytest
+from flask import Flask, got_request_exception
+from pydantic import JsonValue
+from sqlalchemy import Connection, Engine, delete, event, select, text, update
+from sqlalchemy.engine import ExecutionContext
+from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
+from sqlalchemy.pool import QueuePool
+from werkzeug.test import TestResponse
+
+import controllers.console.explore.trial as trial_module
+import controllers.console.wraps as console_wraps
+import libs.login as login_module
+import services.app_service as app_module
+from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN
+from controllers.console import api as console_api
+from controllers.console.app import preview_admission as admission_module
+from core.agent.entities import AgentToolEntity
+from core.helper import encrypter
+from core.plugin.plugin_service import PluginService
+from core.tools.__base.tool import Tool
+from core.tools.entities.tool_entities import ToolParameter
+from core.tools.tool_manager import ToolManager
+from core.workflow.llm_environment_variable import LLMEnvironmentVariable
+from enums import DeploymentEdition
+from extensions.ext_login import DifyLoginManager, unauthorized_handler
+from graphon.variables import SecretVariable, StringVariable
+from libs.external_api import ExternalApi
+from libs.token import generate_csrf_token
+from machinery.errors import ActiveWorkspaceRequiredError
+from models import Account, AccountTrialAppRecord, App, AppMode, Tenant, TrialApp
+from models.account import AccountStatus
+from models.enums import CustomizeTokenStrategy, TagType
+from models.model import AppModelConfig, IconType, Site, Tag, TagBinding
+from models.provider_ids import GenericProviderID
+from models.tools import WorkflowToolProvider
+from models.workflow import Workflow
+from repositories.app_preview_query_repository import AppPreviewQueryRepository
+from repositories.trial_app_repository import TrialAppRepository
+from services.app_preview_details_adapters import AppPreviewDetailsRuntime
+from services.app_preview_query_service import AppPreviewQueryService
+from services.app_service import AppResponseView, AppService
+from services.recommended_app_query_service import (
+ RecommendedAppCatalogPage,
+ RecommendedAppDetailRecord,
+ RecommendedAppQueryService,
+)
+
+_Endpoint = Literal["detail", "workflows"]
+_CREATED_AT = datetime(2024, 1, 1)
+_UPDATED_AT = datetime(2024, 1, 2)
+
+
+@dataclass
+class _Catalog:
+ engine: Engine
+ ids: set[str] = field(default_factory=set)
+ calls: list[str] = field(default_factory=list)
+
+ def contains(self, app_id: str) -> bool:
+ assert isinstance(self.engine.pool, QueuePool)
+ assert self.engine.pool.checkedout() == 0
+ self.calls.append(app_id)
+ return app_id in self.ids
+
+ def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
+ raise AssertionError(f"Unexpected listing for {language}")
+
+ def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
+ raise AssertionError(f"Unexpected listing for {language}")
+
+ def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
+ raise AssertionError(f"Unexpected catalog detail for {app_id}")
+
+
+@dataclass
+class _ExternalIO:
+ engine: Engine
+ owner_id: str
+ viewer_workspace_id: str
+ decryptions: list[tuple[str, str]] = field(default_factory=list)
+ tool_calls: list[tuple[str, str, str]] = field(default_factory=list)
+
+ def encrypt(self, tenant_id: str, token: str) -> str:
+ assert tenant_id == self.owner_id
+ assert token == "workflow-secret"
+ return "cipher-workflow"
+
+ def decrypt(self, tenant_id: str, token: str) -> str:
+ assert isinstance(self.engine.pool, QueuePool)
+ assert self.engine.pool.checkedout() == 0
+ self.decryptions.append((tenant_id, token))
+ if token == "cipher-workflow":
+ assert tenant_id == self.owner_id
+ return "workflow-secret"
+ assert token == "cipher-tool"
+ assert tenant_id == self.viewer_workspace_id
+ return "tool-secret"
+
+ def tool_runtime(self, *, tenant_id: str, app_id: str, agent_tool: AgentToolEntity, user_id: str) -> Tool:
+ assert isinstance(self.engine.pool, QueuePool)
+ assert self.engine.pool.checkedout() == 0
+ assert tenant_id == self.viewer_workspace_id
+ assert agent_tool.tool_name == "search"
+ self.tool_calls.append((tenant_id, app_id, user_id))
+ parameter = ToolParameter.model_validate(
+ {"name": "api_key", "label": {"en_US": "Key"}, "type": "secret-input", "form": "form"}
+ )
+ return cast(
+ Tool, _ToolRuntime(SimpleNamespace(parameters=[parameter], identity=SimpleNamespace(name="search")))
+ )
+
+
+@dataclass
+class _ToolRuntime:
+ entity: SimpleNamespace
+
+ def get_runtime_parameters(self) -> list[ToolParameter]:
+ return []
+
+
+@dataclass(frozen=True)
+class _ApplicationServices:
+ app_previews: AppPreviewQueryService
+ app_preview_details: AppPreviewDetailsRuntime
+ recommended_app_queries: RecommendedAppQueryService
+
+
+@dataclass(frozen=True)
+class _Harness:
+ app: Flask
+ owner: Tenant
+ viewer_workspace: Tenant
+ viewer: Account
+ author: Account
+ updater: Account
+ target: App
+ config: AppModelConfig
+ site: Site
+ workflow: Workflow
+ tag: Tag
+ factory: sessionmaker[Session]
+ engine: Engine
+ catalog: _Catalog
+ io: _ExternalIO
+ sessions: list[Session]
+
+ def get(
+ self,
+ endpoint: _Endpoint = "detail",
+ *,
+ method: str = "GET",
+ csrf_token: str | None = None,
+ ) -> TestResponse:
+ suffix = "" if endpoint == "detail" else "/workflows"
+ statements: list[str] = []
+ token = generate_csrf_token(self.viewer.id) if csrf_token is None else csrf_token
+ client = self.app.test_client()
+ client.set_cookie(COOKIE_NAME_CSRF_TOKEN, token)
+
+ def record_statement(
+ _connection: Connection,
+ _cursor: object,
+ statement: str,
+ _parameters: object,
+ _context: ExecutionContext,
+ _executemany: bool,
+ ) -> None:
+ statements.append(statement)
+
+ event.listen(self.engine, "before_cursor_execute", record_statement)
+ try:
+ response = client.open(
+ f"/trial-apps/{self.target.id}{suffix}", method=method, headers={HEADER_NAME_CSRF_TOKEN: token}
+ )
+ finally:
+ event.remove(self.engine, "before_cursor_execute", record_statement)
+ assert not [sql for sql in statements if sql.lstrip().upper().startswith(("INSERT ", "UPDATE ", "DELETE "))]
+ assert response.headers["Content-Type"] == "application/json"
+ if method != "HEAD":
+ assert int(response.headers["Content-Length"]) == len(response.data)
+ assert isinstance(self.engine.pool, QueuePool)
+ assert self.engine.pool.checkedout() == 0
+ assert all(not session.in_transaction() and not session.identity_map for session in self.sessions)
+ return response
+
+ def assert_app_not_queried(self) -> None:
+ assert self.sessions == []
+ assert self.catalog.calls == []
+ assert self.io.tool_calls == []
+ assert self.io.decryptions == []
+
+ def legacy_detail(self) -> dict[str, object]:
+ """The unchanged ORM response view is the pre-migration serialization oracle."""
+ with self.app.test_request_context(f"/trial-apps/{self.target.id}"), self.factory() as session:
+ app = session.get(App, self.target.id)
+ assert app is not None
+ app = AppService().get_app(app, session=session)
+ return trial_module.TrialAppDetailResponse.model_validate(
+ AppResponseView(app, session=session), from_attributes=True
+ ).model_dump(mode="json")
+
+
+@pytest.fixture
+def harness(
+ monkeypatch: pytest.MonkeyPatch,
+ config_overrides: Callable[..., None],
+ sqlite_engine: Engine,
+ sqlite_session_factory: sessionmaker[Session],
+) -> _Harness:
+ config_overrides(
+ LOGIN_DISABLED=False,
+ DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
+ INIT_PASSWORD="",
+ SECRET_KEY="preview-admission-test-secret-key",
+ CONSOLE_WEB_URL="",
+ CONSOLE_API_URL="",
+ SERVICE_API_URL="",
+ APP_WEB_URL="",
+ )
+ owner = Tenant(name="App owner")
+ viewer_workspace = Tenant(name="Viewer workspace")
+ viewer = Account(name="Viewer", email="viewer@example.com", status=AccountStatus.ACTIVE)
+ viewer._current_tenant = viewer_workspace
+ author = Account(name="Author", email="author@example.com")
+ updater = Account(name="Updater", email="updater@example.com")
+ target = App(
+ id=str(uuid4()),
+ tenant_id=owner.id,
+ name="Preview app",
+ description="",
+ mode=AppMode.CHAT,
+ icon_type=IconType.EMOJI,
+ icon="",
+ icon_background=None,
+ enable_site=False,
+ enable_api=False,
+ max_active_requests=0,
+ created_at=_CREATED_AT,
+ updated_at=_UPDATED_AT,
+ )
+ config = AppModelConfig(
+ app_id=target.id,
+ opening_statement="",
+ suggested_questions="[]",
+ model='{"provider":"example/model","name":"model","mode":"chat","completion_params":{"temperature":0}}',
+ agent_mode='{"enabled":false,"strategy":"function_call","tools":[]}',
+ user_input_form='[{"number":{"label":"Count","variable":"count","default":0,"required":false}}]',
+ )
+ config.created_at = _CREATED_AT
+ config.updated_at = _UPDATED_AT
+ site = Site(
+ app_id=target.id,
+ title="Preview site",
+ default_language="en-US",
+ customize_token_strategy=CustomizeTokenStrategy.UUID,
+ code="preview-code",
+ icon_type=IconType.EMOJI,
+ icon="",
+ icon_background=None,
+ chat_color_theme="",
+ description=None,
+ custom_disclaimer="",
+ show_workflow_steps=False,
+ )
+ site.created_at = _CREATED_AT
+ site.updated_at = _UPDATED_AT
+ io = _ExternalIO(sqlite_engine, owner.id, viewer_workspace.id)
+ monkeypatch.setattr(encrypter, "encrypt_token", io.encrypt)
+ monkeypatch.setattr(encrypter, "decrypt_token", io.decrypt)
+ monkeypatch.setattr(ToolManager, "get_agent_tool_runtime", io.tool_runtime)
+ monkeypatch.setattr(ToolManager, "get_hardcoded_provider", lambda _provider_id: object())
+ with sqlite_session_factory.begin() as session:
+ session.add_all([owner, viewer_workspace, viewer, author, updater, target, config, site])
+ session.flush()
+ target.created_by = author.id
+ target.updated_by = updater.id
+ target.app_model_config_id = config.id
+ workflow = Workflow.new(
+ tenant_id=owner.id,
+ app_id=target.id,
+ type="workflow",
+ version="draft",
+ graph='{"nodes":[],"edges":[]}',
+ features='{"suggested_questions_after_answer":{"enabled":false}}',
+ created_by=author.id,
+ environment_variables=[
+ SecretVariable(id="secret", name="api_key", value="workflow-secret"),
+ LLMEnvironmentVariable(
+ id="llm", name="shared_model", value={"provider": "provider", "name": "model", "mode": "chat"}
+ ),
+ ],
+ conversation_variables=[
+ StringVariable(id="topic", name="topic", value="sqlite", selector=["conversation", "topic"])
+ ],
+ rag_pipeline_variables=[],
+ )
+ workflow.created_at = _CREATED_AT
+ workflow.updated_at = _UPDATED_AT
+ workflow.updated_by = updater.id
+ target.workflow_id = workflow.id
+ tag = Tag(tenant_id=owner.id, type=TagType.APP, name="Owner tag", created_by=author.id)
+ session.add_all([workflow, tag])
+ session.flush()
+ session.add_all(
+ [
+ TrialApp(app_id=target.id, tenant_id=str(uuid4()), trial_limit=0),
+ AccountTrialAppRecord(app_id=target.id, account_id=viewer.id, count=100),
+ TagBinding(tenant_id=owner.id, tag_id=tag.id, target_id=target.id, created_by=author.id),
+ WorkflowToolProvider(
+ name="trial-workflow",
+ label="Trial workflow",
+ icon="icon",
+ app_id=target.id,
+ version="1.0.0",
+ user_id=author.id,
+ tenant_id=owner.id,
+ description="Workflow provider",
+ parameter_configuration="[]",
+ ),
+ ]
+ )
+ factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
+ sessions: list[Session] = []
+
+ @event.listens_for(factory, "after_begin")
+ def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
+ assert all(previous is session or not previous.in_transaction() for previous in sessions)
+ sessions.append(session)
+
+ catalog = _Catalog(sqlite_engine)
+ recommendations = RecommendedAppQueryService(
+ catalog=catalog, trial_apps=TrialAppRepository(factory), trial_enabled=False
+ )
+ services = _ApplicationServices(
+ app_previews=AppPreviewQueryService(
+ apps=AppPreviewQueryRepository(session_factory=factory), is_previewable=recommendations.is_previewable
+ ),
+ app_preview_details=AppPreviewDetailsRuntime(details=AppPreviewQueryRepository(session_factory=factory)),
+ recommended_app_queries=recommendations,
+ )
+ for module in (trial_module, admission_module):
+ monkeypatch.setattr(module, "application_services", lambda: services)
+ monkeypatch.setattr(login_module, "current_user", viewer)
+ monkeypatch.setattr(app_module, "current_user", viewer)
+ monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: True)
+ app = Flask(__name__)
+ app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False)
+ login_manager = DifyLoginManager()
+ login_manager.init_app(app)
+ login_manager.unauthorized_handler(unauthorized_handler)
+ api = ExternalApi(app)
+ api.error_handlers = console_api.error_handlers.copy()
+ api.add_resource(trial_module.AppApi, "/trial-apps/")
+ api.add_resource(trial_module.AppWorkflowApi, "/trial-apps//workflows")
+ return _Harness(
+ app,
+ owner,
+ viewer_workspace,
+ viewer,
+ author,
+ updater,
+ target,
+ config,
+ site,
+ workflow,
+ tag,
+ sqlite_session_factory,
+ sqlite_engine,
+ catalog,
+ io,
+ sessions,
+ )
+
+
+def _assert_error(response: TestResponse, status: int, code: str) -> None:
+ assert response.status_code == status, response.get_json()
+ body = response.get_json()
+ assert body["status"] == status
+ assert body["code"] == code
+ assert body["message"]
+
+
+@pytest.mark.parametrize(
+ "mode", [AppMode.CHAT, AppMode.COMPLETION, AppMode.ADVANCED_CHAT, AppMode.WORKFLOW, AppMode.AGENT_CHAT]
+)
+def test_app_detail_keeps_original_response_fields_and_stored_audit_times(harness: _Harness, mode: AppMode) -> None:
+ with harness.factory.begin() as session:
+ session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
+ expected = harness.legacy_detail()
+ if mode == AppMode.AGENT_CHAT:
+ expected_config = expected["model_config"]
+ assert isinstance(expected_config, dict)
+ # Legacy masking could autoflush a response-only config mutation and bump
+ # this timestamp. A read-only preview returns the actual stored audit value.
+ expected_config["updated_at"] = int(_UPDATED_AT.timestamp())
+
+ response = harness.get()
+
+ assert response.status_code == 200, response.get_json()
+ assert response.get_json() == expected
+ body = response.get_json()
+ assert body["max_active_requests"] == 0
+ assert body["enable_site"] is False
+ assert body["tags"] == [{"id": harness.tag.id, "name": "Owner tag", "type": "app"}]
+ assert body["api_base_url"] == "http://localhost/v1"
+ assert body["site"]["app_base_url"] == "http://localhost"
+ assert body["site"]["access_token"] == "preview-code"
+ with harness.factory() as session:
+ assert session.scalar(select(AccountTrialAppRecord.count)) == 100
+ assert (
+ session.scalar(select(AppModelConfig.updated_at).where(AppModelConfig.id == harness.config.id))
+ == _UPDATED_AT
+ )
+
+
+def test_legacy_agent_preview_masks_tools_without_upgrading_mode_or_mutating_config(harness: _Harness) -> None:
+ agent_mode = {
+ "enabled": True,
+ "strategy": "function_call",
+ "tools": [
+ {
+ "provider_type": "builtin",
+ "provider_id": "example",
+ "tool_name": "search",
+ "tool_parameters": {"api_key": "cipher-tool"},
+ }
+ ],
+ }
+ with harness.factory.begin() as session:
+ session.execute(
+ update(AppModelConfig)
+ .where(AppModelConfig.id == harness.config.id)
+ .values(agent_mode=json.dumps(agent_mode))
+ )
+ app_updated_at = session.scalar(select(App.updated_at).where(App.id == harness.target.id))
+ config_updated_at = session.scalar(
+ select(AppModelConfig.updated_at).where(AppModelConfig.id == harness.config.id)
+ )
+
+ response = harness.get()
+
+ assert response.status_code == 200, response.get_json()
+ body = response.get_json()
+ assert body["mode"] == "agent-chat"
+ assert body["model_config"]["agent_mode"]["tools"][0]["tool_parameters"] == {"api_key": "to*******et"}
+ assert harness.io.tool_calls == [(harness.viewer_workspace.id, harness.target.id, harness.viewer.id)]
+ assert harness.io.decryptions == [(harness.viewer_workspace.id, "cipher-tool")]
+ assert harness.viewer_workspace.id != harness.owner.id
+ with harness.factory() as session:
+ app = session.get(App, harness.target.id)
+ config = session.get(AppModelConfig, harness.config.id)
+ assert app is not None
+ assert config is not None
+ assert app.mode == AppMode.CHAT
+ assert app.updated_at == app_updated_at
+ assert config.agent_mode == json.dumps(agent_mode)
+ assert config.updated_at == config_updated_at
+
+
+@pytest.mark.parametrize(
+ "provider_fields",
+ [{}, {"provider_id": None}, {"provider_id": 42}, {"provider_id": ""}, {"provider_id": "invalid/provider"}],
+ ids=["missing", "null", "non-string", "empty", "invalid-format"],
+)
+def test_invalid_builtin_config_does_not_break_detail_or_valid_tool_masking(
+ harness: _Harness, monkeypatch: pytest.MonkeyPatch, provider_fields: dict[str, JsonValue]
+) -> None:
+ agent_mode: dict[str, JsonValue] = {
+ "enabled": True,
+ "strategy": "function_call",
+ "tools": [
+ {
+ "provider_type": "builtin",
+ "tool_name": "broken",
+ "tool_parameters": {"api_key": "cipher-secret"},
+ "credential_id": None,
+ **provider_fields,
+ },
+ {
+ "provider_type": "builtin",
+ "provider_id": "example",
+ "tool_name": "search",
+ "tool_parameters": {"api_key": "cipher-tool"},
+ },
+ ],
+ }
+ with harness.factory.begin() as session:
+ session.execute(
+ update(AppModelConfig)
+ .where(AppModelConfig.id == harness.config.id)
+ .values(agent_mode=json.dumps(agent_mode))
+ )
+
+ def hardcoded_provider(provider_id: str) -> object:
+ if provider_id == "example":
+ return object()
+ raise KeyError(provider_id)
+
+ def tool_runtime(*, tenant_id: str, app_id: str, agent_tool: AgentToolEntity, user_id: str) -> Tool:
+ if agent_tool.provider_id != "example":
+ raise ValueError("Invalid historical provider ID")
+ return harness.io.tool_runtime(tenant_id=tenant_id, app_id=app_id, agent_tool=agent_tool, user_id=user_id)
+
+ plugin_queries: list[str] = []
+
+ def check_plugins(tenant_id: str, provider_ids: Sequence[GenericProviderID]) -> Sequence[bool]:
+ assert tenant_id == harness.owner.id
+ plugin_queries.extend(str(provider_id) for provider_id in provider_ids)
+ return [False] * len(provider_ids)
+
+ monkeypatch.setattr(ToolManager, "get_hardcoded_provider", hardcoded_provider)
+ monkeypatch.setattr(ToolManager, "get_agent_tool_runtime", tool_runtime)
+ monkeypatch.setattr(PluginService, "check_tools_existence", check_plugins)
+
+ response = harness.get()
+
+ assert response.status_code == 200, response.get_json()
+ body = response.get_json()
+ assert body["deleted_tools"] == []
+ assert body["model_config"]["agent_mode"]["tools"][0]["tool_parameters"] == {}
+ assert body["model_config"]["agent_mode"]["tools"][1]["tool_parameters"] == {"api_key": "to*******et"}
+ assert b"cipher-secret" not in response.data
+ assert plugin_queries == []
+ assert harness.io.tool_calls == [(harness.viewer_workspace.id, harness.target.id, harness.viewer.id)]
+ with harness.factory() as session:
+ assert session.scalar(
+ select(AppModelConfig.agent_mode).where(AppModelConfig.id == harness.config.id)
+ ) == json.dumps(agent_mode)
+
+
+@pytest.mark.parametrize("failure", ["short-config", "runtime", "masking"])
+def test_unmaskable_tool_parameters_are_removed_from_detail_without_changing_storage(
+ harness: _Harness, monkeypatch: pytest.MonkeyPatch, failure: str
+) -> None:
+ broken_tool: dict[str, JsonValue] = {
+ "provider_type": "builtin",
+ "tool_parameters": {"api_key": "cipher-secret"},
+ }
+ if failure != "short-config":
+ broken_tool.update(provider_id="broken", tool_name="broken")
+ agent_mode: dict[str, JsonValue] = {
+ "enabled": True,
+ "strategy": "function_call",
+ "tools": [
+ broken_tool,
+ {
+ "provider_type": "builtin",
+ "provider_id": "example",
+ "tool_name": "search",
+ "tool_parameters": {"api_key": "cipher-tool"},
+ },
+ ],
+ }
+ with harness.factory.begin() as session:
+ session.execute(
+ update(AppModelConfig)
+ .where(AppModelConfig.id == harness.config.id)
+ .values(agent_mode=json.dumps(agent_mode))
+ )
+
+ class MaskingFailureRuntime(_ToolRuntime):
+ parameter_queries: int = 0
+
+ @override
+ def get_runtime_parameters(self) -> list[ToolParameter]:
+ self.parameter_queries += 1
+ if self.parameter_queries == 2:
+ raise RuntimeError("Tool metadata became unavailable during masking")
+ return []
+
+ parameter = ToolParameter.model_validate(
+ {"name": "api_key", "label": {"en_US": "Key"}, "type": "secret-input", "form": "form"}
+ )
+ broken_runtime = MaskingFailureRuntime(
+ SimpleNamespace(parameters=[parameter], identity=SimpleNamespace(name="broken"))
+ )
+
+ def tool_runtime(*, tenant_id: str, app_id: str, agent_tool: AgentToolEntity, user_id: str) -> Tool:
+ if agent_tool.provider_id == "example":
+ return harness.io.tool_runtime(tenant_id=tenant_id, app_id=app_id, agent_tool=agent_tool, user_id=user_id)
+ if failure == "runtime":
+ raise RuntimeError("Tool provider is unavailable")
+ return cast(Tool, broken_runtime)
+
+ def decrypt(tenant_id: str, token: str) -> str:
+ if token == "cipher-secret":
+ assert tenant_id == harness.viewer_workspace.id
+ return "plain-secret"
+ return harness.io.decrypt(tenant_id, token)
+
+ monkeypatch.setattr(ToolManager, "get_agent_tool_runtime", tool_runtime)
+ monkeypatch.setattr(encrypter, "decrypt_token", decrypt)
+
+ response = harness.get()
+
+ assert response.status_code == 200, response.get_json()
+ tools = response.get_json()["model_config"]["agent_mode"]["tools"]
+ assert tools[0]["tool_parameters"] == {}
+ assert tools[1]["tool_parameters"] == {"api_key": "to*******et"}
+ assert b"cipher-secret" not in response.data
+ assert b"plain-secret" not in response.data
+ assert broken_runtime.parameter_queries == (2 if failure == "masking" else 0)
+ with harness.factory() as session:
+ assert session.scalar(
+ select(AppModelConfig.agent_mode).where(AppModelConfig.id == harness.config.id)
+ ) == json.dumps(agent_mode)
+
+
+def test_workflow_is_anonymous_and_masks_secrets_after_query_session_closes(
+ harness: _Harness, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(login_module, "current_user", None)
+ monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: False)
+ response = harness.get("workflows", csrf_token="")
+ assert response.status_code == 200, response.get_json()
+ assert response.get_json() == {
+ "id": harness.workflow.id,
+ "graph": {"nodes": [], "edges": []},
+ "features": {"suggested_questions_after_answer": {"enabled": False}},
+ "hash": harness.workflow.unique_hash,
+ "version": "draft",
+ "marked_name": "",
+ "marked_comment": "",
+ "created_by": {"id": harness.author.id, "name": "Author", "email": "author@example.com"},
+ "created_at": int(_CREATED_AT.timestamp()),
+ "updated_by": {"id": harness.updater.id, "name": "Updater", "email": "updater@example.com"},
+ "updated_at": int(_UPDATED_AT.timestamp()),
+ "tool_published": True,
+ "environment_variables": [
+ {
+ "value_type": "secret",
+ "value": encrypter.full_mask_token(),
+ "id": "secret",
+ "name": "api_key",
+ "description": "",
+ "selector": ["env", "api_key"],
+ },
+ {
+ "value_type": "llm",
+ "value": {"provider": "provider", "name": "model", "mode": "chat"},
+ "id": "llm",
+ "name": "shared_model",
+ "description": "",
+ "selector": ["env", "shared_model"],
+ },
+ ],
+ "conversation_variables": [
+ {"id": "topic", "name": "topic", "value_type": "string", "value": "sqlite", "description": ""}
+ ],
+ "rag_pipeline_variables": [],
+ }
+ assert harness.io.decryptions == [(harness.owner.id, "cipher-workflow")]
+ assert b"workflow-secret" not in response.data
+ assert b"cipher-workflow" not in response.data
+
+
+@pytest.mark.parametrize("endpoint", ["detail", "workflows"])
+def test_catalog_only_admission_keeps_preview_available(harness: _Harness, endpoint: _Endpoint) -> None:
+ with harness.factory.begin() as session:
+ session.execute(delete(TrialApp).where(TrialApp.app_id == harness.target.id))
+ harness.catalog.ids.add(harness.target.id)
+ response = harness.get(endpoint)
+ assert response.status_code == 200, response.get_json()
+ assert harness.catalog.calls == [harness.target.id]
+
+
+@pytest.mark.parametrize("endpoint", ["detail", "workflows"])
+@pytest.mark.parametrize("unavailable", ["unlisted", "disabled", "missing"])
+def test_unavailable_apps_fail_admission(harness: _Harness, endpoint: _Endpoint, unavailable: str) -> None:
+ with harness.factory.begin() as session:
+ if unavailable == "unlisted":
+ session.execute(delete(TrialApp).where(TrialApp.app_id == harness.target.id))
+ elif unavailable == "disabled":
+ session.execute(text("UPDATE apps SET status='disabled' WHERE id=:app_id"), {"app_id": harness.target.id})
+ else:
+ session.execute(delete(App).where(App.id == harness.target.id))
+ _assert_error(harness.get(endpoint), 404, "app_not_found")
+ assert harness.io.decryptions == []
+
+
+def test_anonymous_app_detail_requires_account(harness: _Harness, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(login_module, "current_user", None)
+ response = harness.get()
+ assert response.status_code == 401
+ assert response.get_json() == {"code": "unauthorized", "message": "Unauthorized."}
+ harness.assert_app_not_queried()
+
+
+@pytest.mark.parametrize(("init_password", "error_code"), [("", "not_setup"), ("setup-password", "not_init_validated")])
+def test_app_detail_requires_setup_before_account_or_app_queries(
+ harness: _Harness,
+ monkeypatch: pytest.MonkeyPatch,
+ config_overrides: Callable[..., None],
+ init_password: str,
+ error_code: str,
+) -> None:
+ config_overrides(INIT_PASSWORD=init_password)
+ monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: False)
+ monkeypatch.setattr(login_module, "current_user", None)
+ _assert_error(harness.get(csrf_token=""), 401, error_code)
+ harness.assert_app_not_queried()
+
+
+@pytest.mark.parametrize("csrf_token", ["", "invalid-signed-token"], ids=["missing", "invalid"])
+def test_app_detail_rejects_invalid_csrf_before_app_queries(harness: _Harness, csrf_token: str) -> None:
+ response = harness.get(csrf_token=csrf_token)
+ _assert_error(response, 401, "unauthorized")
+ assert response.get_json()["message"] == "CSRF token is missing or invalid."
+ harness.assert_app_not_queried()
+
+
+def test_app_detail_rejects_csrf_token_for_another_account(harness: _Harness) -> None:
+ response = harness.get(csrf_token=generate_csrf_token(harness.author.id))
+ _assert_error(response, 401, "unauthorized")
+ assert response.get_json()["message"] == "CSRF token is missing or invalid."
+ harness.assert_app_not_queried()
+
+
+def test_app_detail_rejects_uninitialized_account_before_app_queries(harness: _Harness) -> None:
+ harness.viewer.status = AccountStatus.UNINITIALIZED
+ _assert_error(harness.get(), 400, "account_not_initialized")
+ harness.assert_app_not_queried()
+
+
+def test_missing_active_workspace_uses_shared_console_error_and_monitoring(harness: _Harness) -> None:
+ harness.viewer._current_tenant = None
+ errors: list[BaseException] = []
+
+ def record_error(_sender: object, *, exception: BaseException) -> None:
+ errors.append(exception)
+
+ with got_request_exception.connected_to(record_error):
+ response = harness.get()
+
+ _assert_error(response, 500, "active_workspace_required")
+ assert response.get_json()["message"] == "Internal Server Error"
+ assert len(errors) == 1
+ assert isinstance(errors[0], ActiveWorkspaceRequiredError)
+ harness.assert_app_not_queried()
+
+
+def test_deleted_admitted_account_is_unauthorized(harness: _Harness) -> None:
+ with harness.factory.begin() as session:
+ session.execute(delete(Account).where(Account.id == harness.viewer.id))
+ _assert_error(harness.get(), 401, "unauthorized")
+
+
+def test_missing_site_is_specific_error(harness: _Harness) -> None:
+ with harness.factory.begin() as session:
+ session.execute(delete(Site).where(Site.app_id == harness.target.id))
+ _assert_error(harness.get(), 403, "app_site_unavailable")
+
+
+def test_missing_model_config_retains_nullable_detail(harness: _Harness) -> None:
+ with harness.factory.begin() as session:
+ session.execute(delete(AppModelConfig).where(AppModelConfig.id == harness.config.id))
+ response = harness.get()
+ assert response.status_code == 200, response.get_json()
+ assert response.get_json()["model_config"] is None
+
+
+@pytest.mark.parametrize("missing", ["reference", "workflow"])
+def test_missing_workflow_is_app_unavailable(harness: _Harness, missing: str) -> None:
+ with harness.factory.begin() as session:
+ if missing == "reference":
+ session.execute(update(App).where(App.id == harness.target.id).values({App.workflow_id: None}))
+ else:
+ session.execute(delete(Workflow).where(Workflow.id == harness.workflow.id))
+ _assert_error(harness.get("workflows"), 400, "app_unavailable")
+
+
+@pytest.mark.parametrize("endpoint", ["detail", "workflows"])
+def test_head_keeps_get_status_and_headers_without_response_body(harness: _Harness, endpoint: _Endpoint) -> None:
+ response = harness.get(endpoint)
+ head = harness.get(endpoint, method="HEAD")
+ assert response.status_code == head.status_code == 200
+ assert head.data == b""
+ assert head.headers == response.headers
+
+
+@pytest.mark.parametrize(
+ ("endpoint", "relation"), [("detail", "config"), ("detail", "workflow"), ("workflows", "workflow")]
+)
+def test_cross_owner_configuration_is_not_exposed(harness: _Harness, endpoint: _Endpoint, relation: str) -> None:
+ with harness.factory.begin() as session:
+ if relation == "config":
+ session.execute(
+ update(AppModelConfig).where(AppModelConfig.id == harness.config.id).values(app_id=str(uuid4()))
+ )
+ else:
+ session.execute(update(Workflow).where(Workflow.id == harness.workflow.id).values(tenant_id=str(uuid4())))
+ _assert_error(harness.get(endpoint), 400, "app_unavailable")
+ assert harness.io.decryptions == []
diff --git a/api/tests/unit_tests/core/workflow/test_environment_variables.py b/api/tests/unit_tests/core/workflow/test_environment_variables.py
new file mode 100644
index 00000000000..7949e909e5b
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/test_environment_variables.py
@@ -0,0 +1,84 @@
+import json
+
+import pytest
+
+from core.helper import encrypter
+from core.workflow.environment_variables import load_environment_variables
+from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
+from graphon.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
+from graphon.variables.exc import VariableError
+
+
+@pytest.mark.parametrize(
+ ("tenant_id", "serialized_variables"),
+ [(None, "invalid JSON"), ("", "invalid JSON"), ("tenant", None), ("tenant", ""), ("tenant", "{}")],
+)
+def test_load_empty_or_ownerless_environment_variables(tenant_id: str | None, serialized_variables: str | None) -> None:
+ assert load_environment_variables(tenant_id=tenant_id, serialized_variables=serialized_variables) == []
+
+
+def test_load_environment_variables_preserves_order_types_and_decrypts_only_secrets(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ variables = [
+ IntegerVariable(name="count", value=0, selector=["env", "count"]),
+ SecretVariable(name="token", value="ciphertext", selector=["env", "token"]),
+ StringVariable(name="text", value="", selector=["env", "text"]),
+ FloatVariable(name="ratio", value=1.5, selector=["env", "ratio"]),
+ LLMEnvironmentVariable(
+ name="model",
+ value={"provider": "langgenius/anthropic/anthropic", "name": "claude-sonnet", "mode": "chat"},
+ selector=["env", "model"],
+ ),
+ ]
+ serialized = json.dumps({var.name: dump_environment_variable(var, mode="json") for var in variables})
+ decrypt_calls: list[tuple[str, str]] = []
+
+ def decrypt(*, tenant_id: str, token: str) -> str:
+ decrypt_calls.append((tenant_id, token))
+ return "plaintext"
+
+ monkeypatch.setattr(encrypter, "decrypt_token", decrypt)
+ loaded = load_environment_variables(tenant_id="owner-tenant", serialized_variables=serialized)
+
+ assert loaded == [variables[0], variables[1].model_copy(update={"value": "plaintext"}), *variables[2:]]
+ assert [type(var) for var in loaded] == [type(var) for var in variables]
+ assert decrypt_calls == [("owner-tenant", "ciphertext")]
+ assert variables[1].value == "ciphertext"
+
+
+def test_load_environment_variables_propagates_invalid_json() -> None:
+ with pytest.raises(json.JSONDecodeError):
+ load_environment_variables(tenant_id="tenant", serialized_variables="invalid JSON")
+
+
+def test_load_environment_variables_validates_all_variables_before_decryption(monkeypatch: pytest.MonkeyPatch) -> None:
+ decrypt_calls: list[tuple[str, str]] = []
+
+ def decrypt(*, tenant_id: str, token: str) -> str:
+ decrypt_calls.append((tenant_id, token))
+ return "plaintext"
+
+ monkeypatch.setattr(encrypter, "decrypt_token", decrypt)
+ serialized = json.dumps(
+ {"secret": {"name": "secret", "value_type": "secret", "value": "ciphertext"}, "invalid": {}}
+ )
+
+ with pytest.raises(VariableError, match="missing name"):
+ load_environment_variables(tenant_id="tenant", serialized_variables=serialized)
+ assert decrypt_calls == []
+
+
+def test_load_environment_variables_propagates_decryption_failure(monkeypatch: pytest.MonkeyPatch) -> None:
+ failure = RuntimeError("credential store unavailable")
+
+ def decrypt(*, tenant_id: str, token: str) -> str:
+ assert (tenant_id, token) == ("tenant", "ciphertext")
+ raise failure
+
+ monkeypatch.setattr(encrypter, "decrypt_token", decrypt)
+ serialized = json.dumps({"secret": {"name": "secret", "value_type": "secret", "value": "ciphertext"}})
+
+ with pytest.raises(RuntimeError) as caught:
+ load_environment_variables(tenant_id="tenant", serialized_variables=serialized)
+ assert caught.value is failure
diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py
index 0f763bb57c6..59540ab86fe 100644
--- a/api/tests/unit_tests/extensions/test_ext_application_services.py
+++ b/api/tests/unit_tests/extensions/test_ext_application_services.py
@@ -19,7 +19,8 @@ from extensions import ext_application_services
from extensions.ext_redis import RedisClientWrapper
from machinery.context import RequestContext
from models.account import Account
-from models.model import AccountTrialAppRecord, App, DifySetup, TrialApp
+from models.enums import CustomizeTokenStrategy
+from models.model import AccountTrialAppRecord, App, DifySetup, Site, TrialApp
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.account_oauth_repository import (
@@ -765,6 +766,43 @@ def test_app_previews_use_the_configured_catalog_and_app_owner(
services.app_previews.get_access(app_id=other_id)
+def test_app_preview_details_use_the_configured_database_without_request_globals(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ services = ext_application_services.build_application_services(
+ database_client=sqlite_session_factory,
+ deployment_edition=DeploymentEdition.COMMUNITY,
+ initialization_password="",
+ redis=MagicMock(spec=RedisClientWrapper),
+ )
+ app_id, owner_id, viewer_workspace_id = str(uuid4()), str(uuid4()), str(uuid4())
+ account = Account(name="Preview viewer", email="preview@example.com")
+ with sqlite_session_factory.begin() as session:
+ session.add_all(
+ [
+ account,
+ App(id=app_id, tenant_id=owner_id, name="Preview", mode="chat", enable_site=True, enable_api=False),
+ Site(
+ app_id=app_id,
+ title="Preview site",
+ default_language="en-US",
+ customize_token_strategy=CustomizeTokenStrategy.UUID,
+ ),
+ ]
+ )
+
+ detail = services.app_preview_details.get_detail(
+ app=AppPreviewRef(app_id=app_id, tenant_id=owner_id),
+ account_id=account.id,
+ active_workspace_id=viewer_workspace_id,
+ )
+
+ assert detail.id == app_id
+ assert detail.name == "Preview"
+ assert detail.site.title == "Preview site"
+ assert detail.model_config is None
+
+
def test_build_application_services_adapts_enterprise_webapp_access_mode(
sqlite_session_factory: sessionmaker[Session],
) -> None:
diff --git a/api/tests/unit_tests/repositories/test_app_preview_query_repository.py b/api/tests/unit_tests/repositories/test_app_preview_query_repository.py
index 903dd4d437b..31ea2abadfc 100644
--- a/api/tests/unit_tests/repositories/test_app_preview_query_repository.py
+++ b/api/tests/unit_tests/repositories/test_app_preview_query_repository.py
@@ -1,21 +1,27 @@
"""Preview reads enforce app ownership without adding execution or dataset ACL policy."""
-from dataclasses import replace
+import json
+from dataclasses import asdict, replace
from datetime import datetime
-from typing import Literal
+from typing import Literal, override
from uuid import uuid4
import pytest
-from sqlalchemy import Engine, select, text
+from sqlalchemy import Connection, Engine, ExecutionContext, event, select, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import QueuePool
-from models.account import Tenant, TenantStatus
+from core.tools.entities.tool_entities import ApiProviderSchemaType
+from graphon.variables import StringVariable
+from models.account import Account, Tenant, TenantStatus
from models.dataset import Dataset
from models.enums import CustomizeTokenStrategy
-from models.model import App, AppMode, IconType, Site
+from models.model import App, AppMode, AppModelConfig, IconType, Site
+from models.tools import ApiToolProvider, WorkflowToolProvider
+from models.workflow import Workflow, WorkflowType
from repositories.app_preview_query_repository import AppPreviewQueryRepository
from services.app_definition_query_service import AppSiteConfiguration
+from services.app_preview_details_service import AppPreviewAccount
from services.app_preview_query_service import AppPreviewDataset, AppPreviewRef
_APP_ID = "11111111-1111-1111-1111-111111111111"
@@ -340,3 +346,277 @@ def test_preview_queries_leave_caller_transaction_open_and_uncommitted(
with sqlite_session_factory() as session:
assert session.scalar(select(App.name).where(App.id == _APP_ID)) == "Preview"
assert sqlite_engine.pool.checkedout() == 0
+
+
+@pytest.fixture
+def detail_config(sqlite_session_factory: sessionmaker[Session], preview_ref: AppPreviewRef) -> AppModelConfig:
+ config = AppModelConfig(
+ app_id=preview_ref.app_id,
+ opening_statement="Stored opening",
+ agent_mode=json.dumps(
+ {
+ "enabled": True,
+ "strategy": "react",
+ "tools": [
+ {
+ "provider_type": "builtin",
+ "provider_id": "acme/search/search",
+ "tool_name": "search",
+ "tool_parameters": {"api_key": "encrypted-tool-secret"},
+ }
+ ],
+ }
+ ),
+ )
+ account = Account(name="Viewer", email="preview@example.com")
+ account.id = _CREATOR_ID
+ with sqlite_session_factory.begin() as session:
+ session.add_all([account, config])
+ session.flush()
+ app = session.get(App, preview_ref.app_id)
+ assert app is not None
+ app.app_model_config_id = config.id
+ return config
+
+
+def test_get_detail_returns_read_only_detached_values_before_mode_and_tool_enrichment(
+ sqlite_engine: Engine,
+ sqlite_session_factory: sessionmaker[Session],
+ preview_ref: AppPreviewRef,
+ detail_config: AppModelConfig,
+) -> None:
+ closed: list[Session] = []
+ mutated: list[object] = []
+ writes: list[str] = []
+
+ class TrackedSession(Session):
+ @override
+ def close(self) -> None:
+ mutated.extend(self.new)
+ mutated.extend(self.dirty)
+ mutated.extend(self.deleted)
+ super().close()
+ closed.append(self)
+
+ def record_writes(
+ _connection: Connection,
+ _cursor: object,
+ statement: str,
+ _parameters: object,
+ _context: ExecutionContext,
+ _executemany: bool,
+ ) -> None:
+ if statement.lstrip().split(maxsplit=1)[0].upper() in {"INSERT", "UPDATE", "DELETE"}:
+ writes.append(statement)
+
+ factory: sessionmaker[Session] = sessionmaker(bind=sqlite_engine, class_=TrackedSession, expire_on_commit=False)
+ repository = AppPreviewQueryRepository(session_factory=factory)
+ event.listen(sqlite_engine, "before_cursor_execute", record_writes)
+ try:
+ record = repository.get_detail(app=preview_ref, account_id=_CREATOR_ID)
+ finally:
+ event.remove(sqlite_engine, "before_cursor_execute", record_writes)
+
+ assert isinstance(sqlite_engine.pool, QueuePool)
+ assert sqlite_engine.pool.checkedout() == 0
+ assert closed
+ assert all(not session.in_transaction() and not session.identity_map for session in closed)
+ assert mutated == []
+ assert writes == []
+ assert record.detail.id == preview_ref.app_id
+ assert record.detail.mode == AppMode.CHAT
+ assert record.detail.site.title == "Preview Site"
+ assert record.detail.model_config is not None
+ assert record.detail.model_config["opening_statement"] == "Stored opening"
+ assert record.detail.model_config["agent_mode"] == json.loads(detail_config.agent_mode or "{}")
+ assert record.detail.deleted_tools == ()
+ assert record.existing_api_provider_ids == frozenset()
+
+ def serialize_date(value: object) -> str:
+ assert isinstance(value, datetime), f"Non-data value escaped repository: {type(value)}"
+ return value.isoformat()
+
+ json.dumps(asdict(record.detail), default=serialize_date)
+ with sqlite_session_factory() as session:
+ assert session.scalar(select(App.mode).where(App.id == preview_ref.app_id)) == AppMode.CHAT
+ assert session.scalar(select(AppModelConfig.agent_mode).where(AppModelConfig.id == detail_config.id)) == (
+ detail_config.agent_mode
+ )
+
+
+def test_get_detail_resolves_only_configured_api_providers_owned_by_app_tenant(
+ sqlite_session_factory: sessionmaker[Session], preview_ref: AppPreviewRef, detail_config: AppModelConfig
+) -> None:
+ owner_provider, viewer_provider, unconfigured_provider = [
+ ApiToolProvider(
+ name=name,
+ icon="icon",
+ schema="{}",
+ schema_type_str=ApiProviderSchemaType.OPENAPI,
+ user_id=_CREATOR_ID,
+ tenant_id=tenant_id,
+ description=name,
+ tools_str="[]",
+ credentials_str="{}",
+ )
+ for name, tenant_id in [
+ ("Owner provider", preview_ref.tenant_id),
+ ("Viewer provider", _DECOY_TENANT_ID),
+ ("Unconfigured provider", preview_ref.tenant_id),
+ ]
+ ]
+ with sqlite_session_factory.begin() as session:
+ session.add_all([owner_provider, viewer_provider, unconfigured_provider])
+ config = session.get(AppModelConfig, detail_config.id)
+ assert config is not None
+ config.agent_mode = json.dumps(
+ {
+ "enabled": False,
+ "tools": [
+ {
+ "provider_type": "api",
+ "provider_id": provider_id,
+ "tool_name": "search",
+ "tool_parameters": {},
+ }
+ for provider_id in [owner_provider.id, viewer_provider.id, owner_provider.id, str(uuid4())]
+ ],
+ }
+ )
+
+ record = AppPreviewQueryRepository(session_factory=sqlite_session_factory).get_detail(
+ app=preview_ref, account_id=_CREATOR_ID
+ )
+
+ assert record.existing_api_provider_ids == frozenset({owner_provider.id})
+ assert record.detail.deleted_tools == ()
+
+
+@pytest.mark.parametrize(
+ "stored_environment",
+ [
+ pytest.param("{invalid-json", id="unparsed-invalid-json"),
+ pytest.param(
+ '{"secret":{"id":"secret","name":"api_key","value_type":"secret",'
+ '"value":"encrypted-workflow-secret","selector":[]}}',
+ id="undecrypted-secret",
+ ),
+ ],
+)
+def test_get_workflow_returns_detached_data_and_raw_environment_after_session_closes(
+ sqlite_engine: Engine,
+ sqlite_session_factory: sessionmaker[Session],
+ preview_ref: AppPreviewRef,
+ stored_environment: str,
+) -> None:
+ author = Account(name="Author", email="author@example.com")
+ author.id = _CREATOR_ID
+ updater = Account(name="Updater", email="updater@example.com")
+ stored_features = json.dumps(
+ {
+ "opening_statement": "Workflow opening",
+ "file_upload": {"image": {"enabled": True, "number_limits": 3, "transfer_methods": ["local_file"]}},
+ }
+ )
+ with sqlite_session_factory.begin() as session:
+ session.add_all([author, updater])
+ session.flush()
+ workflow = Workflow(
+ tenant_id=preview_ref.tenant_id,
+ app_id=preview_ref.app_id,
+ type=WorkflowType.CHAT,
+ version="published",
+ graph='{"nodes":[],"edges":[]}',
+ features=stored_features,
+ created_by=author.id,
+ updated_by=updater.id,
+ marked_name="Release",
+ marked_comment="Preview snapshot",
+ )
+ workflow.created_at = _CREATED_AT
+ workflow.updated_at = _CREATED_AT
+ workflow._environment_variables = stored_environment
+ workflow.conversation_variables = [
+ StringVariable(id="topic", name="topic", value="sqlite", selector=["conversation", "topic"])
+ ]
+ session.add(workflow)
+ session.flush()
+ app = session.get(App, preview_ref.app_id)
+ assert app is not None
+ app.workflow_id = workflow.id
+ session.add(
+ WorkflowToolProvider(
+ name="preview-workflow",
+ label="Preview workflow",
+ icon="icon",
+ app_id=preview_ref.app_id,
+ version="1.0.0",
+ user_id=author.id,
+ tenant_id=preview_ref.tenant_id,
+ description="Workflow provider",
+ parameter_configuration="[]",
+ )
+ )
+
+ closed: list[Session] = []
+ mutated: list[object] = []
+
+ class TrackedSession(Session):
+ @override
+ def close(self) -> None:
+ mutated.extend(self.new)
+ mutated.extend(self.dirty)
+ mutated.extend(self.deleted)
+ super().close()
+ closed.append(self)
+
+ factory: sessionmaker[Session] = sessionmaker(bind=sqlite_engine, class_=TrackedSession, expire_on_commit=False)
+ record = AppPreviewQueryRepository(session_factory=factory).get_workflow(app=preview_ref)
+
+ assert isinstance(sqlite_engine.pool, QueuePool)
+ assert sqlite_engine.pool.checkedout() == 0
+ assert closed
+ assert all(not session.in_transaction() and not session.identity_map for session in closed)
+ assert mutated == []
+ assert record.tenant_id == preview_ref.tenant_id
+ assert record.environment_variables_json == stored_environment
+ result = record.workflow
+ assert result.environment_variables == ()
+ assert result.id == workflow.id
+ assert result.graph == {"nodes": [], "edges": []}
+ assert result.features == {
+ "opening_statement": "Workflow opening",
+ "file_upload": {
+ "enabled": True,
+ "number_limits": 3,
+ "allowed_file_upload_methods": ["local_file"],
+ "allowed_file_types": ["image"],
+ "allowed_file_extensions": [],
+ },
+ }
+ assert result.hash == workflow.unique_hash
+ assert result.version == "published"
+ assert result.marked_name == "Release"
+ assert result.marked_comment == "Preview snapshot"
+ assert result.created_by == AppPreviewAccount(id=author.id, name="Author", email="author@example.com")
+ assert result.updated_by == AppPreviewAccount(id=updater.id, name="Updater", email="updater@example.com")
+ assert result.created_at == _CREATED_AT
+ assert result.updated_at == _CREATED_AT
+ assert result.tool_published is True
+ assert len(result.conversation_variables) == 1
+ assert result.conversation_variables[0]["name"] == "topic"
+ assert result.conversation_variables[0]["value"] == "sqlite"
+ assert result.conversation_variables[0]["value_type"] == "string"
+ assert result.rag_pipeline_variables == ()
+
+ def serialize_date(value: object) -> str:
+ assert isinstance(value, datetime), f"Non-data value escaped repository: {type(value)}"
+ return value.isoformat()
+
+ json.dumps(asdict(record), default=serialize_date)
+ with sqlite_session_factory() as session:
+ assert session.scalar(select(Workflow._features).where(Workflow.id == workflow.id)) == stored_features
+ assert (
+ session.scalar(select(Workflow._environment_variables).where(Workflow.id == workflow.id))
+ == stored_environment
+ )
diff --git a/api/tests/unit_tests/services/test_app_preview_details_adapters.py b/api/tests/unit_tests/services/test_app_preview_details_adapters.py
new file mode 100644
index 00000000000..d83a24d2859
--- /dev/null
+++ b/api/tests/unit_tests/services/test_app_preview_details_adapters.py
@@ -0,0 +1,649 @@
+"""Real preview detail reads preserve ownership, masking and transaction boundaries."""
+
+import json
+from collections.abc import Iterator, Sequence
+from dataclasses import asdict, dataclass
+from datetime import datetime
+from typing import Literal, cast, override
+from uuid import uuid4
+
+import pytest
+from flask import Flask
+from pydantic import JsonValue
+from sqlalchemy import Connection, Engine, ExecutionContext, delete, event, select, text, update
+from sqlalchemy.orm import Session, SessionTransaction, UOWTransaction, sessionmaker
+from sqlalchemy.pool import QueuePool
+
+import core.agent.tool_configuration as tool_configuration_module
+import core.helper.tool_parameter_cache as cache_module
+from core.agent.entities import AgentToolEntity
+from core.helper import encrypter
+from core.plugin.plugin_service import PluginService
+from core.tools.__base.tool import Tool
+from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolEntity, ToolParameter
+from core.tools.tool_manager import ToolManager
+from extensions.ext_database import db
+from graphon.variables import SecretVariable
+from models import Account, App
+from models.account import Tenant
+from models.enums import CustomizeTokenStrategy
+from models.model import AppMode, AppModelConfig, Site
+from models.provider_ids import GenericProviderID
+from models.tools import ApiToolProvider
+from models.workflow import Workflow, WorkflowType
+from repositories.app_preview_query_repository import AppPreviewQueryRepository
+from services.account_errors import AccountNotFoundError
+from services.app_definition_query_service import AppDefinitionUnavailableError
+from services.app_preview_details_adapters import AppPreviewDetailsRuntime
+from services.app_preview_details_service import AppPreviewDetail
+from services.app_preview_query_service import AppPreviewRef, AppPreviewSiteUnavailableError, AppPreviewUnavailableError
+
+
+@dataclass(frozen=True)
+class _Harness:
+ flask_app: Flask
+ target: App
+ decoy: App
+ account: Account
+ active_workspace: Tenant
+ config: AppModelConfig
+ decoy_config: AppModelConfig
+ workflow: Workflow
+ decoy_workflow: Workflow
+ factory: sessionmaker[Session]
+ engine: Engine
+ runtime: AppPreviewDetailsRuntime
+ closed: list[Session]
+ mutated: list[object]
+ commits: list[Session]
+ writes: list[str]
+
+ @property
+ def reference(self) -> AppPreviewRef:
+ return AppPreviewRef(app_id=self.target.id, tenant_id=self.target.tenant_id)
+
+ def detail(self, *, app: AppPreviewRef | None = None, account_id: str | None = None) -> AppPreviewDetail:
+ return self.runtime.get_detail(
+ app=app or self.reference,
+ account_id=account_id or self.account.id,
+ active_workspace_id=self.active_workspace.id,
+ )
+
+ def assert_closed(self) -> None:
+ assert isinstance(self.engine.pool, QueuePool)
+ assert self.engine.pool.checkedout() == 0
+ assert self.closed
+ assert all(not session.in_transaction() and not session.identity_map for session in self.closed)
+
+ def assert_read_only(self) -> None:
+ self.assert_closed()
+ assert self.mutated == []
+ assert self.commits == []
+ assert self.writes == []
+
+
+@pytest.fixture
+def harness(sqlite_engine: Engine, sqlite_session_factory: sessionmaker[Session]) -> Iterator[_Harness]:
+ target = App(
+ id=str(uuid4()), tenant_id=str(uuid4()), name="Preview", mode="chat", enable_site=True, enable_api=True
+ )
+ decoy = App(id=str(uuid4()), tenant_id=str(uuid4()), name="Decoy", mode="chat", enable_site=True, enable_api=True)
+ account = Account(name="Viewer", email="preview@example.com")
+ active_workspace = Tenant(name="Active workspace")
+ config = AppModelConfig(app_id=target.id, opening_statement="Preview opening", pre_prompt="Visible prompt")
+ decoy_config = AppModelConfig(app_id=decoy.id, opening_statement="Other tenant prompt")
+ with sqlite_session_factory.begin() as session:
+ session.add_all([target, decoy, account, active_workspace, config, decoy_config])
+ session.flush()
+ target.app_model_config_id = config.id
+ decoy.app_model_config_id = decoy_config.id
+ workflow = Workflow(
+ tenant_id=target.tenant_id,
+ app_id=target.id,
+ type=WorkflowType.CHAT,
+ version="published",
+ graph='{"nodes":[],"edges":[]}',
+ features='{"opening_statement":"Workflow opening"}',
+ created_by=account.id,
+ updated_by=account.id,
+ )
+ decoy_workflow = Workflow(
+ tenant_id=decoy.tenant_id,
+ app_id=decoy.id,
+ type=WorkflowType.CHAT,
+ version="published",
+ graph='{"nodes":[],"edges":[]}',
+ features='{"opening_statement":"Other tenant workflow"}',
+ created_by=account.id,
+ )
+ session.add_all([workflow, decoy_workflow])
+ session.flush()
+ target.workflow_id = workflow.id
+ decoy.workflow_id = decoy_workflow.id
+ session.add_all(
+ [
+ Site(
+ app_id=target.id,
+ title="Preview site",
+ default_language="en-US",
+ customize_token_strategy=CustomizeTokenStrategy.UUID,
+ ),
+ Site(
+ app_id=decoy.id,
+ title="Decoy site",
+ default_language="en-US",
+ customize_token_strategy=CustomizeTokenStrategy.UUID,
+ ),
+ ]
+ )
+ closed: list[Session] = []
+ mutated: list[object] = []
+ commits: list[Session] = []
+ writes: list[str] = []
+
+ def record_changes(session: Session) -> None:
+ mutated.extend(session.new)
+ mutated.extend(session.dirty)
+ mutated.extend(session.deleted)
+
+ class TrackedSession(Session):
+ @override
+ def close(self) -> None:
+ record_changes(self)
+ super().close()
+ closed.append(self)
+
+ factory: sessionmaker[Session] = sessionmaker(bind=sqlite_engine, class_=TrackedSession, expire_on_commit=False)
+
+ @event.listens_for(factory, "before_flush")
+ def before_flush(session: Session, _context: UOWTransaction, _instances: Sequence[object] | None) -> None:
+ record_changes(session)
+
+ @event.listens_for(factory, "before_commit")
+ def before_commit(session: Session) -> None:
+ commits.append(session)
+
+ def before_cursor_execute(
+ _connection: Connection,
+ _cursor: object,
+ statement: str,
+ _parameters: object,
+ _context: ExecutionContext,
+ _executemany: bool,
+ ) -> None:
+ if statement.lstrip().split(maxsplit=1)[0].upper() in {"INSERT", "UPDATE", "DELETE"}:
+ writes.append(statement)
+
+ @event.listens_for(factory, "after_begin")
+ def after_begin(_session: Session, _transaction: SessionTransaction, connection: Connection) -> None:
+ event.listen(connection, "before_cursor_execute", before_cursor_execute)
+
+ flask_app = Flask(__name__)
+ flask_app.config.update(TESTING=True, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
+ db.init_app(flask_app)
+ yield _Harness(
+ flask_app,
+ target,
+ decoy,
+ account,
+ active_workspace,
+ config,
+ decoy_config,
+ workflow,
+ decoy_workflow,
+ sqlite_session_factory,
+ sqlite_engine,
+ AppPreviewDetailsRuntime(details=AppPreviewQueryRepository(session_factory=factory)),
+ closed,
+ mutated,
+ commits,
+ writes,
+ )
+ with flask_app.app_context():
+ db.session.remove()
+ db.engine.dispose()
+
+
+def _serialize_date(value: object) -> str:
+ assert isinstance(value, datetime), f"Non-data value escaped runtime: {type(value)}"
+ return value.isoformat()
+
+
+@pytest.mark.parametrize("endpoint", ["detail", "workflow"])
+def test_preview_result_contains_only_detached_data(harness: _Harness, endpoint: Literal["detail", "workflow"]) -> None:
+ with harness.flask_app.app_context():
+ result = harness.detail() if endpoint == "detail" else harness.runtime.get_workflow(app=harness.reference)
+ assert result.id == (harness.target.id if endpoint == "detail" else harness.workflow.id)
+ json.dumps(asdict(result), default=_serialize_date)
+ if isinstance(result, AppPreviewDetail):
+ assert result.site.title == "Preview site"
+ assert result.model_config is not None
+ assert result.model_config["opening_statement"] == "Preview opening"
+ assert result.workflow is not None
+ assert result.workflow.id == harness.workflow.id
+ else:
+ assert result.features["opening_statement"] == "Workflow opening"
+ assert result.created_by is not None
+ assert result.created_by.id == harness.account.id
+ assert result.updated_by == result.created_by
+ harness.assert_read_only()
+
+
+@pytest.mark.parametrize("plugin_failure", [False, True])
+def test_deleted_tools_check_owner_scoped_provider_snapshot_after_session_closes(
+ harness: _Harness, monkeypatch: pytest.MonkeyPatch, plugin_failure: bool
+) -> None:
+ own_provider = ApiToolProvider(
+ name="Owner API",
+ icon="icon",
+ schema="{}",
+ schema_type_str=ApiProviderSchemaType.OPENAPI,
+ user_id=harness.account.id,
+ tenant_id=harness.target.tenant_id,
+ description="Owner provider",
+ tools_str="[]",
+ credentials_str="{}",
+ )
+ other_provider = ApiToolProvider(
+ name="Viewer API",
+ icon="icon",
+ schema="{}",
+ schema_type_str=ApiProviderSchemaType.OPENAPI,
+ user_id=harness.account.id,
+ tenant_id=harness.active_workspace.id,
+ description="Provider from another tenant",
+ tools_str="[]",
+ credentials_str="{}",
+ )
+ missing_api_id = str(uuid4())
+ configured_tools = [
+ ("builtin", "acme/missing/missing", "missing-plugin"),
+ ("builtin", "local", "hardcoded"),
+ ("api", other_provider.id, "other-tenant-api"),
+ ("builtin", "acme/present/present", "present-plugin"),
+ ("api", own_provider.id, "owner-api"),
+ ("api", missing_api_id, "missing-api"),
+ ]
+ with harness.factory.begin() as session:
+ session.add_all([own_provider, other_provider])
+ session.execute(
+ update(AppModelConfig)
+ .where(AppModelConfig.id == harness.config.id)
+ .values(
+ agent_mode=json.dumps(
+ {
+ "enabled": False,
+ "tools": [
+ {
+ "provider_type": kind,
+ "provider_id": provider_id,
+ "tool_name": name,
+ "tool_parameters": {},
+ }
+ for kind, provider_id, name in configured_tools
+ ],
+ }
+ )
+ )
+ )
+ calls: list[tuple[str, tuple[str, ...]]] = []
+
+ def hardcoded_provider(provider_id: str) -> object:
+ harness.assert_closed()
+ if provider_id == "local":
+ return object()
+ raise KeyError(provider_id)
+
+ def check_plugins(tenant_id: str, provider_ids: Sequence[GenericProviderID]) -> Sequence[bool]:
+ harness.assert_read_only()
+ calls.append((tenant_id, tuple(str(provider_id) for provider_id in provider_ids)))
+ if plugin_failure:
+ raise RuntimeError("Plugin daemon unavailable")
+ return [False, True]
+
+ monkeypatch.setattr(ToolManager, "get_hardcoded_provider", hardcoded_provider)
+ monkeypatch.setattr(PluginService, "check_tools_existence", check_plugins)
+ with harness.flask_app.app_context():
+ if plugin_failure:
+ with pytest.raises(RuntimeError, match="Plugin daemon unavailable"):
+ harness.detail()
+ else:
+ detail = harness.detail()
+ assert [asdict(tool) for tool in detail.deleted_tools] == [
+ {"type": "builtin", "tool_name": "missing-plugin", "provider_id": "acme/missing/missing"},
+ {"type": "api", "tool_name": "other-tenant-api", "provider_id": other_provider.id},
+ {"type": "api", "tool_name": "missing-api", "provider_id": missing_api_id},
+ ]
+ assert calls == [(harness.target.tenant_id, ("acme/missing/missing", "acme/present/present"))]
+ harness.assert_read_only()
+
+
+@pytest.mark.parametrize(
+ "malformed_fields",
+ [
+ {},
+ {"provider_id": None},
+ {"provider_id": 1},
+ {"provider_id": {}},
+ {"provider_id": ""},
+ {"provider_id": "invalid/provider-id"},
+ ],
+ ids=["missing", "null", "number", "object", "empty", "invalid-format"],
+)
+def test_deleted_tools_ignore_invalid_builtin_config_and_keep_valid_missing_provider(
+ harness: _Harness, monkeypatch: pytest.MonkeyPatch, malformed_fields: dict[str, JsonValue]
+) -> None:
+ configured_tools: list[dict[str, JsonValue]] = [
+ {
+ "provider_type": "builtin",
+ "tool_name": "invalid-plugin",
+ "tool_parameters": {},
+ "enabled": True,
+ **malformed_fields,
+ },
+ {
+ "provider_type": "builtin",
+ "provider_id": "acme/missing/missing",
+ "tool_name": "missing-plugin",
+ "tool_parameters": {},
+ },
+ ]
+ with harness.factory.begin() as session:
+ session.execute(
+ update(AppModelConfig)
+ .where(AppModelConfig.id == harness.config.id)
+ .values(agent_mode=json.dumps({"enabled": False, "tools": configured_tools}))
+ )
+ calls: list[tuple[str, tuple[str, ...]]] = []
+
+ def hardcoded_provider(provider_id: str) -> object:
+ harness.assert_closed()
+ raise KeyError(provider_id)
+
+ def check_plugins(tenant_id: str, provider_ids: Sequence[GenericProviderID]) -> Sequence[bool]:
+ harness.assert_read_only()
+ calls.append((tenant_id, tuple(str(provider_id) for provider_id in provider_ids)))
+ return [False]
+
+ monkeypatch.setattr(ToolManager, "get_hardcoded_provider", hardcoded_provider)
+ monkeypatch.setattr(PluginService, "check_tools_existence", check_plugins)
+ with harness.flask_app.app_context():
+ detail = harness.detail()
+
+ assert calls == [(harness.target.tenant_id, ("acme/missing/missing",))]
+ assert [asdict(tool) for tool in detail.deleted_tools] == [
+ {"type": "builtin", "tool_name": "missing-plugin", "provider_id": "acme/missing/missing"}
+ ]
+ assert detail.model_config is not None
+ agent_mode = detail.model_config["agent_mode"]
+ assert isinstance(agent_mode, dict)
+ assert agent_mode["tools"] == configured_tools
+ harness.assert_read_only()
+
+
+@pytest.mark.parametrize("endpoint", ["detail", "workflow"])
+@pytest.mark.parametrize("change", ["missing", "tenant", "status"])
+def test_app_scope_never_falls_back_to_another_valid_app(
+ harness: _Harness, endpoint: Literal["detail", "workflow"], change: Literal["missing", "tenant", "status"]
+) -> None:
+ reference = harness.reference
+ if change == "missing":
+ reference = AppPreviewRef(app_id=str(uuid4()), tenant_id=reference.tenant_id)
+ elif change == "tenant":
+ reference = AppPreviewRef(app_id=reference.app_id, tenant_id=harness.decoy.tenant_id)
+ else:
+ with harness.factory.begin() as session:
+ session.execute(
+ text("UPDATE apps SET status = 'disabled' WHERE id = :app_id"), {"app_id": reference.app_id}
+ )
+ get = harness.detail if endpoint == "detail" else harness.runtime.get_workflow
+ with harness.flask_app.app_context(), pytest.raises(AppPreviewUnavailableError):
+ get(app=reference)
+ harness.assert_closed()
+
+
+def test_detail_rejects_missing_account(harness: _Harness) -> None:
+ account_id = str(uuid4())
+ with harness.flask_app.app_context(), pytest.raises(AccountNotFoundError, match=account_id):
+ harness.detail(account_id=account_id)
+ harness.assert_closed()
+
+
+def test_detail_rejects_missing_site(harness: _Harness) -> None:
+ with harness.factory.begin() as session:
+ session.execute(delete(Site).where(Site.app_id == harness.target.id))
+ with harness.flask_app.app_context(), pytest.raises(AppPreviewSiteUnavailableError):
+ harness.detail()
+ harness.assert_closed()
+
+
+def test_detail_rejects_config_link_to_another_app(harness: _Harness) -> None:
+ with harness.factory.begin() as session:
+ session.execute(
+ update(App).where(App.id == harness.target.id).values({App.app_model_config_id: harness.decoy_config.id})
+ )
+ with harness.flask_app.app_context(), pytest.raises(AppDefinitionUnavailableError):
+ harness.detail()
+ harness.assert_closed()
+
+
+def test_detail_missing_model_config_is_optional(harness: _Harness) -> None:
+ with harness.factory.begin() as session:
+ session.execute(delete(AppModelConfig).where(AppModelConfig.id == harness.config.id))
+ with harness.flask_app.app_context():
+ result = harness.detail()
+ assert result.model_config is None
+ harness.assert_closed()
+
+
+def test_detail_closes_session_if_model_config_cannot_be_decoded(harness: _Harness) -> None:
+ with harness.factory.begin() as session:
+ session.execute(update(AppModelConfig).where(AppModelConfig.id == harness.config.id).values(model="{"))
+ with harness.flask_app.app_context(), pytest.raises(json.JSONDecodeError):
+ harness.detail()
+ harness.assert_closed()
+
+
+@pytest.mark.parametrize("endpoint", ["detail", "workflow"])
+@pytest.mark.parametrize("wrong_owner", ["app", "tenant"])
+def test_workflow_link_checks_both_app_and_tenant(
+ harness: _Harness, endpoint: Literal["detail", "workflow"], wrong_owner: Literal["app", "tenant"]
+) -> None:
+ values = {"app_id": harness.decoy.id} if wrong_owner == "app" else {"tenant_id": harness.decoy.tenant_id}
+ with harness.factory.begin() as session:
+ session.execute(update(Workflow).where(Workflow.id == harness.workflow.id).values(values))
+ get = harness.detail if endpoint == "detail" else harness.runtime.get_workflow
+ with harness.flask_app.app_context(), pytest.raises(AppDefinitionUnavailableError):
+ get(app=harness.reference)
+ harness.assert_closed()
+
+
+@pytest.mark.parametrize("endpoint", ["detail", "workflow"])
+def test_missing_workflow_is_optional_only_in_app_detail(
+ harness: _Harness, endpoint: Literal["detail", "workflow"]
+) -> None:
+ with harness.factory.begin() as session:
+ session.execute(delete(Workflow).where(Workflow.id == harness.workflow.id))
+ with harness.flask_app.app_context():
+ if endpoint == "detail":
+ assert harness.detail().workflow is None
+ else:
+ with pytest.raises(AppDefinitionUnavailableError):
+ harness.runtime.get_workflow(app=harness.reference)
+ harness.assert_closed()
+
+
+@pytest.mark.parametrize("legacy_agent", [False, True])
+def test_detail_computes_legacy_mode_without_writes_or_committing_callers_session(
+ harness: _Harness, legacy_agent: bool
+) -> None:
+ if legacy_agent:
+ with harness.factory.begin() as session:
+ session.execute(
+ update(AppModelConfig)
+ .where(AppModelConfig.id == harness.config.id)
+ .values(agent_mode=json.dumps({"enabled": True, "strategy": "react", "tools": []}))
+ )
+ with harness.flask_app.app_context():
+ caller = db.session()
+ account = caller.get(Account, harness.account.id)
+ assert account is not None
+ account.name = "Uncommitted caller edit"
+ result = harness.detail()
+ assert db.session() is caller
+ assert account in caller.dirty
+ assert result.mode == (AppMode.AGENT_CHAT if legacy_agent else AppMode.CHAT)
+ harness.assert_read_only()
+ with harness.factory() as session:
+ assert session.scalar(select(Account.name).where(Account.id == account.id)) == "Viewer"
+ assert session.scalar(select(App.mode).where(App.id == harness.target.id)) == AppMode.CHAT
+
+
+@pytest.mark.parametrize("failure", [False, True])
+def test_workflow_secret_decryption_happens_after_query_session_closes(
+ harness: _Harness, monkeypatch: pytest.MonkeyPatch, failure: bool
+) -> None:
+ secret = SecretVariable(id=str(uuid4()), name="api_key", value="encrypted", selector=["env", "api_key"])
+ with harness.factory.begin() as session:
+ session.execute(
+ update(Workflow)
+ .where(Workflow.id == harness.workflow.id)
+ .values({Workflow._environment_variables: json.dumps({secret.name: secret.model_dump(mode="json")})})
+ )
+ calls: list[str] = []
+
+ def decrypt(tenant_id: str, token: str) -> str:
+ harness.assert_closed()
+ assert token == "encrypted"
+ calls.append(tenant_id)
+ if failure:
+ raise RuntimeError("Key service unavailable")
+ return "decrypted-secret"
+
+ monkeypatch.setattr(encrypter, "decrypt_token", decrypt)
+ with harness.flask_app.app_context():
+ if failure:
+ with pytest.raises(RuntimeError, match="Key service unavailable"):
+ harness.runtime.get_workflow(app=harness.reference)
+ else:
+ result = harness.runtime.get_workflow(app=harness.reference)
+ assert result.environment_variables[0]["value"] == "decrypted-secret"
+ assert result.environment_variables[0]["value_type"] == "secret"
+ assert calls == [harness.target.tenant_id]
+ harness.assert_closed()
+
+
+@dataclass
+class _ExternalTool:
+ entity: ToolEntity
+
+ def get_runtime_parameters(self) -> list[ToolParameter]:
+ return []
+
+
+class _EmptyCache:
+ def get(self, key: str) -> None:
+ assert key
+
+ def setex(self, key: str, expiry: int, value: str) -> None:
+ assert key
+ assert expiry > 0
+ assert value
+
+
+@pytest.mark.parametrize("failure", ["none", "runtime", "decrypt"])
+def test_agent_tool_masking_returns_a_copy_without_dirtying_or_flushing_models(
+ harness: _Harness, monkeypatch: pytest.MonkeyPatch, failure: Literal["none", "runtime", "decrypt"]
+) -> None:
+ provider_id = str(uuid4())
+ agent_mode = {
+ "enabled": True,
+ "strategy": "react",
+ "tools": [
+ {
+ "provider_type": "workflow",
+ "provider_id": provider_id,
+ "tool_name": "search",
+ "tool_parameters": {"api_key": "encrypted"},
+ }
+ ],
+ }
+ with harness.factory.begin() as session:
+ session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.AGENT_CHAT))
+ session.execute(
+ update(AppModelConfig)
+ .where(AppModelConfig.id == harness.config.id)
+ .values(agent_mode=json.dumps(agent_mode))
+ )
+ tool = _ExternalTool(
+ ToolEntity.model_validate(
+ {
+ "identity": {"author": "test", "name": "search", "label": {"en_US": "Search"}, "provider": provider_id},
+ "parameters": [{"name": "api_key", "label": {"en_US": "Key"}, "type": "secret-input", "form": "form"}],
+ }
+ )
+ )
+ calls: list[str] = []
+ checkedout_during_io: list[int] = []
+
+ def runtime(*, tenant_id: str, app_id: str, agent_tool: AgentToolEntity, user_id: str) -> Tool:
+ assert isinstance(harness.engine.pool, QueuePool)
+ checkedout_during_io.append(harness.engine.pool.checkedout())
+ harness.assert_closed()
+ assert tenant_id == harness.active_workspace.id
+ assert tenant_id != harness.target.tenant_id
+ assert app_id == harness.target.id
+ assert user_id == harness.account.id
+ assert agent_tool.tool_name == "search"
+ calls.append(tenant_id)
+ if failure == "runtime":
+ raise RuntimeError("Tool provider unavailable")
+ return cast(Tool, tool)
+
+ def decrypt(tenant_id: str, token: str) -> str:
+ assert isinstance(harness.engine.pool, QueuePool)
+ checkedout_during_io.append(harness.engine.pool.checkedout())
+ harness.assert_closed()
+ assert tenant_id == harness.active_workspace.id
+ assert token == "encrypted"
+ if failure == "decrypt":
+ raise RuntimeError("Credential provider unavailable")
+ return "secret-value"
+
+ monkeypatch.setattr(tool_configuration_module.ToolManager, "get_agent_tool_runtime", runtime)
+ monkeypatch.setattr(cache_module, "redis_client", _EmptyCache())
+ monkeypatch.setattr(encrypter, "decrypt_token", decrypt)
+ original_agent_mode = json.dumps(agent_mode)
+ with harness.flask_app.app_context():
+ result = harness.detail()
+ masked_copy = tool_configuration_module.mask_agent_tool_parameters(
+ agent_mode=cast(dict[str, JsonValue], agent_mode),
+ app_id=harness.target.id,
+ tenant_id=harness.active_workspace.id,
+ user_id=harness.account.id,
+ )
+ assert result.model_config is not None
+ masked_agent_mode = result.model_config["agent_mode"]
+ assert isinstance(masked_agent_mode, dict)
+ tools = masked_agent_mode["tools"]
+ assert isinstance(tools, list)
+ masked_tool = tools[0]
+ assert isinstance(masked_tool, dict)
+ # The credential helper can fall back to ciphertext, which is still masked.
+ # Without a tool runtime, the response must omit unverified parameter values.
+ expected_parameters: dict[str, dict[str, str]] = {
+ "none": {"api_key": "se********ue"},
+ "runtime": {},
+ "decrypt": {"api_key": "en*****ed"},
+ }
+ assert masked_tool["tool_parameters"] == expected_parameters[failure]
+ assert masked_copy == masked_agent_mode
+ assert masked_copy is not agent_mode
+ assert json.dumps(agent_mode) == original_agent_mode
+ assert calls == [harness.active_workspace.id, harness.active_workspace.id]
+ assert checkedout_during_io == ([0, 0] if failure == "runtime" else [0, 0, 0, 0])
+ with harness.factory() as session:
+ persisted = session.get(AppModelConfig, harness.config.id)
+ assert persisted is not None
+ assert persisted.agent_mode_dict == agent_mode
+ harness.assert_read_only()
diff --git a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts
index 99e3cd70b61..15ae5b56f51 100644
--- a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts
+++ b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts
@@ -310,7 +310,7 @@ export const tasks = {
}
/**
- * Get workflow detail
+ * Get a detached workflow definition after catalog preview admission
*/
export const get5 = oc
.route({
@@ -318,7 +318,7 @@ export const get5 = oc
method: 'GET',
operationId: 'getTrialAppsByAppIdWorkflows',
path: '/trial-apps/{app_id}/workflows',
- summary: 'Get workflow detail',
+ summary: 'Get a detached workflow definition after catalog preview admission',
tags: ['console'],
})
.input(z.object({ params: zGetTrialAppsByAppIdWorkflowsPath }))
@@ -331,7 +331,7 @@ export const workflows = {
}
/**
- * Get app detail
+ * Get app detail using the viewer's workspace for tool configuration
*/
export const get6 = oc
.route({
@@ -339,7 +339,7 @@ export const get6 = oc
method: 'GET',
operationId: 'getTrialAppsByAppId',
path: '/trial-apps/{app_id}',
- summary: 'Get app detail',
+ summary: "Get app detail using the viewer's workspace for tool configuration",
tags: ['console'],
})
.input(z.object({ params: zGetTrialAppsByAppIdPath }))