mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
refactor(api): isolate trial app and workflow detail previews (#42659)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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(
|
||||
|
||||
+2
-30
@@ -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]):
|
||||
|
||||
@@ -9351,7 +9351,7 @@ Remove one or more tag bindings from a target.
|
||||
| 200 | Success | **application/json**: [TagResponse](#tagresponse)<br> |
|
||||
|
||||
### [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)<br> |
|
||||
|
||||
### [GET] /trial-apps/{app_id}/workflows
|
||||
**Get workflow detail**
|
||||
**Get a detached workflow definition after catalog preview admission**
|
||||
|
||||
#### Parameters
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
@@ -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: ...
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<uuid:app_id>")
|
||||
api.add_resource(trial_module.AppWorkflowApi, "/trial-apps/<uuid:app_id>/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 == []
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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 }))
|
||||
|
||||
Reference in New Issue
Block a user