mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
1844 lines
81 KiB
Python
1844 lines
81 KiB
Python
"""Tests for application-service dependency wiring."""
|
|
|
|
import json
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from decimal import Decimal
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
from unittest.mock import MagicMock, call, create_autospec, patch
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
from flask import Flask
|
|
from redis import Redis
|
|
from sqlalchemy import event, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from core.credit_usage import CreditUsageAppType
|
|
from core.llm_generator.llm_generator import LLMGenerator
|
|
from core.model_context import get_credit_usage_metadata, use_credit_usage_metadata
|
|
from core.tools.tool_file_manager import ToolFileManager
|
|
from enums import DeploymentEdition, WebAppAccessMode
|
|
from extensions import ext_application_services
|
|
from extensions.application_services.app import AppServices
|
|
from extensions.ext_database import db
|
|
from extensions.ext_redis import RedisClientWrapper
|
|
from machinery.context import RequestContext
|
|
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
|
from models.enums import CustomizeTokenStrategy
|
|
from models.model import (
|
|
AccountTrialAppRecord,
|
|
App,
|
|
AppMode,
|
|
AppModelConfig,
|
|
Conversation,
|
|
DifySetup,
|
|
InstalledApp,
|
|
Message,
|
|
Site,
|
|
TrialApp,
|
|
)
|
|
from models.provider import ProviderCredential
|
|
from repositories.account.repository import SQLAlchemyAccountRepository
|
|
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
|
|
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
|
|
from repositories.app.site_command_repository import AppSiteCommandRepository
|
|
from repositories.app.tracing_config_repository import SQLAlchemyAppTracingConfigRepository
|
|
from repositories.app_scoped_end_user_repository import AppScopedEndUserRepo
|
|
from repositories.app_statistic_query_repository import AppStatisticQueryRepository
|
|
from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository
|
|
from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository
|
|
from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository
|
|
from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository
|
|
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
|
|
from repositories.upload_file_delivery_repository import UploadFileDeliveryQueryRepository
|
|
from repositories.workflow_app_log_query_repository import WorkflowAppLogQueryRepository
|
|
from repositories.workflow_run_archive_repository import WorkflowRunArchiveBundleQueryRepository
|
|
from repositories.workspace.workspace_repository import WorkspaceRepository
|
|
from services import audio_provider_gateway, recommended_app_catalog_gateway
|
|
from services.account import forgot_password_service as account_forgot_password_service
|
|
from services.account.adapters import (
|
|
BillingAccountActivationEligibility,
|
|
BillingWorkspaceMembershipCache,
|
|
DeploymentWorkspaceInvitePolicy,
|
|
RBACWorkspaceMemberAccessSync,
|
|
RedisInvitationTokenStore,
|
|
)
|
|
from services.account.email_registration_adapters import (
|
|
AccountLifecycleRegistrationGateway,
|
|
BillingAccountRegistrationPolicyGateway,
|
|
RedisEmailRegistrationSecurityGateway,
|
|
TokenManagerEmailRegistrationTokenGateway,
|
|
)
|
|
from services.account.forgot_password_adapters import (
|
|
RateLimiterForgotPasswordSendLimiter,
|
|
RedisForgotPasswordSecurityGateway,
|
|
RedisForgotPasswordTokenGateway,
|
|
)
|
|
from services.account.login_adapters import RedisConsoleAuthSecurityGateway
|
|
from services.account.oauth_adapters import (
|
|
AccountActivationOAuthInvitationGateway,
|
|
AccountLifecycleOAuthRegistrationGateway,
|
|
AccountLifecycleOAuthSessionGateway,
|
|
DeploymentOAuthPolicyGateway,
|
|
RedisOAuthAccountClaimLock,
|
|
WorkspaceProvisioningOAuthGateway,
|
|
)
|
|
from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway
|
|
from services.app.api_key_service import AppApiKeyService
|
|
from services.app.creators_platform_gateway import CreatorsPlatformGateway
|
|
from services.app_generate_service import AppGenerateService
|
|
from services.app_preview_query_service import AppPreviewRef, AppPreviewUnavailableError
|
|
from services.app_scoped_end_user_query_service import AppScopedEndUserQueryService
|
|
from services.app_scoped_end_user_service import AppScopedEndUserService
|
|
from services.app_site_service import AppSiteService
|
|
from services.app_tracing_config_gateway import OpsTraceManagerGateway
|
|
from services.app_tracing_config_service import AppTracingConfigService
|
|
from services.audio_types import AudioAppRef, AudioOutput, AudioUpload
|
|
from services.billing_portal_service import BillingPortalService
|
|
from services.billing_service import BillingService
|
|
from services.compliance_download_service import ComplianceDownloadService
|
|
from services.data_source.auth.api_key_service import DataSourceApiKeyAuthService
|
|
from services.data_source.binding_application_service import DataSourceBindingApplicationService
|
|
from services.data_source.credential_gateway import ActorAwareDatasourceCredentialGateway
|
|
from services.data_source.notion_import_adapters import PluginNotionSourceGateway
|
|
from services.data_source.notion_import_application_service import NotionImportApplicationService
|
|
from services.enterprise.enterprise_service import EnterpriseService
|
|
from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError, EnterpriseServiceError
|
|
from services.file_service import FileService
|
|
from services.human_input_file_upload_service import HumanInputFileUploadService
|
|
from services.init_validation_service import InvalidInitializationPasswordError
|
|
from services.installed_app_access_service import (
|
|
InstalledAppAccessDeniedError,
|
|
InstalledAppAccessService,
|
|
InstalledAppRef,
|
|
)
|
|
from services.installed_app_generation_adapters import AppGenerateServiceRuntime as InstalledAppGenerateServiceRuntime
|
|
from services.installed_app_generation_service import InstalledAppGenerationService
|
|
from services.knowledge.api_key_service import DatasetApiKeyService
|
|
from services.knowledge.dataset_access import DatasetAccessService
|
|
from services.knowledge.datasets.application import DatasetApplicationService
|
|
from services.knowledge.document_sync import DocumentSyncApplicationService
|
|
from services.knowledge.documents.application import DatasetDocumentApplicationService
|
|
from services.knowledge.external.application import ExternalKnowledgeApplicationService
|
|
from services.knowledge.indexing.adapters.estimate import IndexingEstimateAdapter, SQLAlchemyProcessRuleReader
|
|
from services.knowledge.indexing.adapters.sources import NotionSourceResolver
|
|
from services.knowledge.indexing.estimate import IndexingEstimateApplicationService
|
|
from services.knowledge.segments.application import DatasetSegmentApplicationService
|
|
from services.message_file_preview_service import MessageFilePreviewService
|
|
from services.oauth_device_application_service import OAuthDeviceApplicationService
|
|
from services.partner_tenant_binding_service import PartnerTenantBindingService
|
|
from services.plugin_file_upload_gateway import ToolFilePluginUploadGateway
|
|
from services.plugin_file_upload_service import PluginFileUploadService
|
|
from services.retention.workflow_run.archive_download_task_cache import WorkflowRunArchiveDownloadTaskCache
|
|
from services.retention.workflow_run.archive_log_service import WorkflowRunArchiveService
|
|
from services.tag_application_service import TagApplicationService
|
|
from services.tool_file_download_service import ToolFileDownloadService
|
|
from services.upload_file_delivery_service import UploadFileDeliveryService
|
|
from services.web_authentication_adapters import (
|
|
AccountWebAuthenticationSecurityGateway,
|
|
PassportWebAppSessionGateway,
|
|
TokenManagerWebAuthenticationGateway,
|
|
)
|
|
from services.web_authentication_service import WebAuthenticationService
|
|
from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway
|
|
from services.webapp_access_query_service import WebAppAccessQueryService, WebAppAccessUnavailableError
|
|
from services.workflow_app_log_query_service import WorkflowAppLogQueryService
|
|
from services.workflow_run_service import WorkflowRunService
|
|
from services.workflow_statistic_query_service import WorkflowStatisticQueryService
|
|
from tasks.delete_conversation_task import delete_conversation_related_data
|
|
from tests.unit_tests.config_override import apply_config_overrides
|
|
from tests.unit_tests.services.test_app_task_service import _StopRedis
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reject_redis_commands(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Service composition must register scripts without performing Redis I/O."""
|
|
|
|
def execute_command(*args: object, **_kwargs: object) -> None:
|
|
pytest.fail(f"Unexpected Redis command: {args[1:]}")
|
|
|
|
monkeypatch.setattr(Redis, "execute_command", execute_command)
|
|
|
|
|
|
def _redis() -> RedisClientWrapper:
|
|
client = RedisClientWrapper()
|
|
client.initialize(Redis())
|
|
return client
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("deployment_edition", "initialization_password", "session_validated", "setup_exists", "expected"),
|
|
[
|
|
pytest.param(DeploymentEdition.CLOUD, "expected", False, False, True, id="cloud"),
|
|
pytest.param(DeploymentEdition.COMMUNITY, "", False, False, True, id="no-password"),
|
|
pytest.param(DeploymentEdition.COMMUNITY, "expected", False, False, False, id="not-validated"),
|
|
pytest.param(DeploymentEdition.ENTERPRISE, "expected", False, False, False, id="enterprise"),
|
|
pytest.param(DeploymentEdition.COMMUNITY, "expected", True, False, True, id="browser-session"),
|
|
pytest.param(DeploymentEdition.COMMUNITY, "expected", False, True, True, id="setup-record"),
|
|
],
|
|
)
|
|
def test_build_application_services_configures_init_validation(
|
|
sqlite_session: Session,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
deployment_edition: DeploymentEdition,
|
|
initialization_password: str,
|
|
session_validated: bool,
|
|
setup_exists: bool,
|
|
expected: bool,
|
|
) -> None:
|
|
if setup_exists:
|
|
sqlite_session.add(DifySetup(version="test-version"))
|
|
sqlite_session.commit()
|
|
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=deployment_edition,
|
|
initialization_password=initialization_password,
|
|
redis=_redis(),
|
|
)
|
|
|
|
assert services.init_validation.is_validated(session_validated=session_validated) is expected
|
|
|
|
|
|
def test_build_application_services_passes_the_expected_password(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="expected",
|
|
redis=_redis(),
|
|
)
|
|
|
|
services.init_validation.validate_password("expected")
|
|
with pytest.raises(InvalidInitializationPasswordError):
|
|
services.init_validation.validate_password("wrong")
|
|
|
|
|
|
def test_init_app_registers_services_for_the_current_app(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
app = Flask(__name__)
|
|
monkeypatch.setattr(ext_application_services, "get_session_maker", lambda: sqlite_session_factory)
|
|
apply_config_overrides(
|
|
monkeypatch,
|
|
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
|
|
INIT_PASSWORD="expected",
|
|
)
|
|
|
|
ext_application_services.init_app(app)
|
|
|
|
with app.app_context():
|
|
services = ext_application_services.application_services()
|
|
assert services is app.extensions["application_services"]
|
|
assert services.init_validation.is_validated(session_validated=False) is False
|
|
assert isinstance(services.app_scoped_end_users.commands, AppScopedEndUserService)
|
|
assert isinstance(services.app_scoped_end_users.queries, AppScopedEndUserQueryService)
|
|
repository = services.app_scoped_end_users.queries._app_scoped_end_users
|
|
assert isinstance(repository, AppScopedEndUserRepo)
|
|
assert services.app_scoped_end_users.commands._app_scoped_end_users is repository
|
|
assert repository._session_factory is sqlite_session_factory
|
|
assert isinstance(services.workflow_statistics, WorkflowStatisticQueryService)
|
|
assert isinstance(services.apps, AppServices)
|
|
assert services.apps.console._apps is services.apps.queries._apps
|
|
creators = services.apps.console._creators
|
|
assert isinstance(creators, CreatorsPlatformGateway)
|
|
assert creators._oauth is services.oauth_server
|
|
|
|
|
|
def test_build_application_services_preserves_composed_boundaries(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
redis = _redis()
|
|
|
|
with patch.object(redis, "register_script", wraps=redis.register_script) as register_script:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=redis,
|
|
)
|
|
|
|
assert isinstance(services.app_api_keys, AppApiKeyService)
|
|
assert isinstance(services.dataset_api_keys, DatasetApiKeyService)
|
|
assert services.dataset_api_keys._access is services.knowledge.datasets._dataset_access
|
|
assert isinstance(services.oauth_device, OAuthDeviceApplicationService)
|
|
assert register_script.call_count == 3
|
|
|
|
assert isinstance(services.installed_apps.generation, InstalledAppGenerationService)
|
|
installed_apps = services.installed_apps.access._installed_apps
|
|
assert isinstance(installed_apps, SQLAlchemyInstalledAppRepository)
|
|
assert services.installed_apps.management._installed_apps is installed_apps
|
|
assert services.installed_apps.generation._usage is installed_apps
|
|
runtime = services.installed_apps.generation._runtime
|
|
assert isinstance(runtime, InstalledAppGenerateServiceRuntime)
|
|
assert runtime._session_factory is sqlite_session_factory
|
|
|
|
trial_apps = services.trial_apps.usage
|
|
assert services.trial_apps.access._apps is trial_apps
|
|
assert services.trial_apps.generation._usage is trial_apps
|
|
assert services.recommended_app_queries._trial_apps is trial_apps
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("deployment_edition", "setup_completed"),
|
|
[
|
|
pytest.param(DeploymentEdition.CLOUD, True, id="cloud"),
|
|
pytest.param(DeploymentEdition.COMMUNITY, False, id="community"),
|
|
pytest.param(DeploymentEdition.ENTERPRISE, False, id="enterprise"),
|
|
],
|
|
)
|
|
def test_build_application_services_configures_setup_policy(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
deployment_edition: DeploymentEdition,
|
|
setup_completed: bool,
|
|
) -> None:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=deployment_edition,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
|
|
assert services.setup.get_status().completed is setup_completed
|
|
assert services.oauth_server is not None
|
|
assert isinstance(services.oauth_device._accounts, SQLAlchemyAccountRepository)
|
|
assert isinstance(services.oauth_device._workspaces, WorkspaceRepository)
|
|
|
|
|
|
def test_build_application_services_wires_builtin_schema_definitions(
|
|
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=_redis(),
|
|
)
|
|
|
|
definitions = services.schema_definitions.list()
|
|
|
|
assert definitions
|
|
assert all({"name", "label", "schema"} <= definition.keys() for definition in definitions)
|
|
|
|
|
|
def test_build_application_services_does_not_construct_schema_manager(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
with patch("extensions.ext_application_services.SchemaManager") as schema_manager:
|
|
ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
|
|
schema_manager.assert_not_called()
|
|
|
|
|
|
def test_build_application_services_wires_tag_boundary(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.tags, TagApplicationService)
|
|
|
|
|
|
def test_build_application_services_reuses_file_service(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.files, FileService)
|
|
assert services.files._session_maker is sqlite_session_factory
|
|
assert services.web_app_runtime._file_service is services.files
|
|
|
|
|
|
def test_build_application_services_wires_message_file_previews(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.message_file_previews, MessageFilePreviewService)
|
|
assert isinstance(services.message_file_previews._files, MessageFilePreviewQueryRepository)
|
|
assert services.message_file_previews._files._session_factory is sqlite_session_factory
|
|
assert services.message_file_previews._storage is ext_application_services.storage
|
|
|
|
|
|
def test_build_application_services_wires_plugin_file_upload_boundary(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.plugin_file_uploads, PluginFileUploadService)
|
|
assert isinstance(services.plugin_file_uploads._owners, SQLAlchemyPluginFileUploadOwnerRepository)
|
|
assert services.plugin_file_uploads._owners._session_factory is sqlite_session_factory
|
|
assert isinstance(services.plugin_file_uploads._files, ToolFilePluginUploadGateway)
|
|
|
|
|
|
def test_build_application_services_wires_tool_file_downloads(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.tool_file_downloads, ToolFileDownloadService)
|
|
assert isinstance(services.tool_file_downloads._tool_files, ToolFileManager)
|
|
|
|
|
|
def test_build_application_services_wires_upload_file_delivery(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.upload_file_delivery, UploadFileDeliveryService)
|
|
assert isinstance(services.upload_file_delivery._files, UploadFileDeliveryQueryRepository)
|
|
assert services.upload_file_delivery._files._session_factory is sqlite_session_factory
|
|
assert services.upload_file_delivery._storage is ext_application_services.storage
|
|
|
|
|
|
def test_build_application_services_wires_workflow_run_archives(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
redis = _redis()
|
|
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=redis,
|
|
)
|
|
|
|
workflow_run_archives = services.workflow_run_archives
|
|
assert isinstance(workflow_run_archives, WorkflowRunArchiveService)
|
|
assert isinstance(workflow_run_archives._bundles, WorkflowRunArchiveBundleQueryRepository)
|
|
assert workflow_run_archives._bundles._session_factory is sqlite_session_factory
|
|
assert isinstance(workflow_run_archives._tasks, WorkflowRunArchiveDownloadTaskCache)
|
|
assert workflow_run_archives._tasks._redis is redis
|
|
assert workflow_run_archives._dispatcher is ext_application_services.dispatch_workflow_run_archive_download_task
|
|
assert workflow_run_archives._sign_download_url is ext_application_services.sign_workflow_run_archive_download_url
|
|
|
|
|
|
def test_build_application_services_wires_human_input_file_uploads(
|
|
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=_redis(),
|
|
)
|
|
|
|
human_input_file_uploads = services.human_input_file_uploads
|
|
assert isinstance(human_input_file_uploads, HumanInputFileUploadService)
|
|
assert isinstance(human_input_file_uploads._uploads, SQLAlchemyHumanInputFileUploadRepository)
|
|
assert human_input_file_uploads._uploads._session_factory is sqlite_session_factory
|
|
assert human_input_file_uploads._remote_files is services.remote_files
|
|
assert human_input_file_uploads._files is services.files
|
|
assert services.remote_files._files is services.files
|
|
|
|
|
|
def test_build_application_services_wires_app_site_boundary(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.app_sites, AppSiteService)
|
|
assert isinstance(services.app_sites._sites, AppSiteCommandRepository)
|
|
assert services.app_sites._sites._session_factory is sqlite_session_factory
|
|
|
|
|
|
def test_build_application_services_wires_web_authentication_boundary(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=create_autospec(RedisClientWrapper, instance=True),
|
|
)
|
|
|
|
assert isinstance(services.web_authentication, WebAuthenticationService)
|
|
assert services.web_authentication._accounts is services.accounts.profile._accounts
|
|
assert isinstance(services.web_authentication._tokens, TokenManagerWebAuthenticationGateway)
|
|
assert (
|
|
services.web_authentication._tokens._access_token_expire_minutes
|
|
== ext_application_services.dify_config.ACCESS_TOKEN_EXPIRE_MINUTES
|
|
)
|
|
assert isinstance(services.web_authentication._security, AccountWebAuthenticationSecurityGateway)
|
|
assert isinstance(services.web_authentication._security._password_security, RedisForgotPasswordSecurityGateway)
|
|
assert isinstance(services.web_authentication._security._login_security, RedisConsoleAuthSecurityGateway)
|
|
assert isinstance(services.web_authentication._app_sessions, PassportWebAppSessionGateway)
|
|
assert services.web_authentication._app_sessions._sessions is services.webapp_access._access
|
|
assert isinstance(services.webapp_access._policy, EnterpriseWebAppAccessPolicyGateway)
|
|
assert services.webapp_access._policy._webapp_auth is EnterpriseService.WebAppAuth
|
|
|
|
|
|
def test_build_application_services_reuses_installed_app_dependencies(
|
|
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=create_autospec(RedisClientWrapper, instance=True),
|
|
)
|
|
|
|
assert isinstance(services.installed_apps.access, InstalledAppAccessService)
|
|
assert isinstance(services.installed_apps.generation, InstalledAppGenerationService)
|
|
assert isinstance(services.installed_apps.access._installed_apps, SQLAlchemyInstalledAppRepository)
|
|
assert services.installed_apps.generation._usage is services.installed_apps.access._installed_apps
|
|
assert isinstance(services.installed_apps.generation._runtime, InstalledAppGenerateServiceRuntime)
|
|
assert services.installed_apps.generation._runtime._session_factory is sqlite_session_factory
|
|
|
|
|
|
def test_build_application_services_wires_app_tracing_config_boundary(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.app_tracing_configs, AppTracingConfigService)
|
|
assert isinstance(services.app_tracing_configs._configs, SQLAlchemyAppTracingConfigRepository)
|
|
assert services.app_tracing_configs._configs._session_factory is sqlite_session_factory
|
|
assert isinstance(services.app_tracing_configs._provider, OpsTraceManagerGateway)
|
|
|
|
|
|
def test_build_application_services_wires_workflow_app_log_boundary(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.workflow_app_logs, WorkflowAppLogQueryService)
|
|
assert isinstance(services.workflow_app_logs._logs, WorkflowAppLogQueryRepository)
|
|
assert services.workflow_app_logs._logs._session_factory is sqlite_session_factory
|
|
|
|
|
|
def test_build_application_services_wires_app_statistic_boundary(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.app_statistics, AppStatisticQueryRepository)
|
|
assert services.app_statistics._session_factory is sqlite_session_factory
|
|
|
|
|
|
def test_build_application_services_wires_workflow_run_service(
|
|
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=_redis(),
|
|
)
|
|
|
|
workflow_runs = services.workflow_runs
|
|
assert isinstance(workflow_runs, WorkflowRunService)
|
|
assert isinstance(workflow_runs._workflow_runs, DifyAPISQLAlchemyWorkflowRunRepository)
|
|
assert workflow_runs._workflow_runs._session_maker is sqlite_session_factory
|
|
|
|
|
|
def test_build_application_services_wires_billing_service(
|
|
sqlite_session: Session,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
account = Account(name="Billing Owner", email="owner@example.com")
|
|
account.id = "account-1"
|
|
sqlite_session.add(account)
|
|
sqlite_session.commit()
|
|
|
|
with (
|
|
patch.object(
|
|
BillingService,
|
|
"get_subscription",
|
|
return_value={"url": "https://billing.example.com/checkout"},
|
|
) as get_subscription,
|
|
patch.object(
|
|
BillingService,
|
|
"get_invoices",
|
|
return_value={"url": "https://billing.example.com/portal"},
|
|
) as get_invoices,
|
|
patch.object(
|
|
BillingService,
|
|
"sync_partner_tenants_bindings",
|
|
return_value={"result": "success"},
|
|
) as sync_partner_tenants_bindings,
|
|
):
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
|
|
request_context = RequestContext(
|
|
request_id="request-1",
|
|
trace_id="trace-1",
|
|
account_id="account-1",
|
|
active_workspace_id="workspace-1",
|
|
)
|
|
assert isinstance(services.billing_portal, BillingPortalService)
|
|
assert services.billing_portal.get_subscription(
|
|
request_context,
|
|
plan="professional",
|
|
interval="month",
|
|
) == {"url": "https://billing.example.com/checkout"}
|
|
assert services.billing_portal.get_invoices(request_context) == {"url": "https://billing.example.com/portal"}
|
|
assert isinstance(services.partner_tenant_bindings, PartnerTenantBindingService)
|
|
assert services.partner_tenant_bindings.sync(
|
|
account_id="account-1",
|
|
partner_key="partner-key",
|
|
click_id="click-1",
|
|
) == {"result": "success"}
|
|
get_subscription.assert_called_once_with("professional", "month", "owner@example.com", "workspace-1")
|
|
get_invoices.assert_called_once_with("owner@example.com", "workspace-1")
|
|
sync_partner_tenants_bindings.assert_called_once_with("account-1", "partner-key", "click-1")
|
|
|
|
|
|
def test_build_application_services_wires_compliance_downloads(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
redis = _redis()
|
|
with (
|
|
patch.object(
|
|
BillingService,
|
|
"get_compliance_download_link",
|
|
return_value={"url": "https://billing.example.com/compliance"},
|
|
) as fetch_link,
|
|
patch("extensions.ext_application_services.RateLimiter") as rate_limiter_type,
|
|
):
|
|
rate_limiter = rate_limiter_type.return_value
|
|
rate_limiter.is_rate_limited.return_value = False
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=redis,
|
|
)
|
|
|
|
assert isinstance(services.compliance_downloads, ComplianceDownloadService)
|
|
assert services.compliance_downloads.get_link(
|
|
request_context=RequestContext(
|
|
request_id="request-1",
|
|
trace_id="trace-1",
|
|
account_id="account-1",
|
|
active_workspace_id="workspace-1",
|
|
),
|
|
document_name="SOC2_Type_II",
|
|
ip_address="127.0.0.1",
|
|
device_info="test-agent",
|
|
) == {"url": "https://billing.example.com/compliance"}
|
|
rate_limiter_type.assert_any_call(
|
|
prefix="compliance_download_rate_limiter",
|
|
max_attempts=4,
|
|
time_window=60,
|
|
redis_client=redis,
|
|
)
|
|
rate_limiter.is_rate_limited.assert_called_once_with("account-1:workspace-1")
|
|
rate_limiter.increment_rate_limit.assert_called_once_with("account-1:workspace-1")
|
|
fetch_link.assert_called_once_with("SOC2_Type_II", "account-1", "workspace-1", "127.0.0.1", "test-agent")
|
|
|
|
|
|
def test_build_application_services_wires_education_rate_limiters(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
redis = _redis()
|
|
with patch("extensions.application_services.account.RateLimiter") as rate_limiter_type:
|
|
ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=redis,
|
|
)
|
|
|
|
rate_limiter_type.assert_any_call(
|
|
prefix="edu_verification_rate_limit",
|
|
max_attempts=10,
|
|
time_window=60,
|
|
redis_client=redis,
|
|
)
|
|
rate_limiter_type.assert_any_call(
|
|
prefix="edu_activation_rate_limit",
|
|
max_attempts=10,
|
|
time_window=60,
|
|
redis_client=redis,
|
|
)
|
|
|
|
|
|
def test_build_application_services_wires_account_profile_repository(
|
|
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=_redis(),
|
|
)
|
|
|
|
accounts = services.accounts.profile._accounts
|
|
assert isinstance(accounts, SQLAlchemyAccountRepository)
|
|
assert accounts._session_factory is sqlite_session_factory
|
|
assert services.accounts.password._accounts is accounts
|
|
assert services.accounts.authentication._passwords is services.accounts.password._passwords
|
|
forgot_password = services.accounts.forgot_password
|
|
assert forgot_password._accounts is accounts
|
|
assert forgot_password._passwords is services.accounts.password._passwords
|
|
tokens = cast(RedisForgotPasswordTokenGateway, forgot_password._tokens)
|
|
send_limiter = cast(RateLimiterForgotPasswordSendLimiter, forgot_password._send_limits)
|
|
security = cast(RedisForgotPasswordSecurityGateway, forgot_password._security)
|
|
assert tokens._redis is send_limiter._rate_limiter._redis_client
|
|
assert send_limiter._rate_limiter.prefix == account_forgot_password_service.FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX
|
|
assert (
|
|
send_limiter._rate_limiter.max_attempts
|
|
== account_forgot_password_service.FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS
|
|
)
|
|
assert (
|
|
security._verification_failure_limit
|
|
== account_forgot_password_service.FORGOT_PASSWORD_VERIFICATION_FAILURE_LIMIT
|
|
)
|
|
assert security._verification_key_prefix == account_forgot_password_service.FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX
|
|
assert services.accounts.initialization._accounts is accounts
|
|
assert not services.accounts.initialization._invitation_required
|
|
assert services.accounts.change_email._accounts is accounts
|
|
email_registration = services.accounts.email_registration
|
|
assert email_registration._accounts is accounts
|
|
assert isinstance(email_registration._tokens, TokenManagerEmailRegistrationTokenGateway)
|
|
assert isinstance(email_registration._security, RedisEmailRegistrationSecurityGateway)
|
|
assert isinstance(email_registration._account_policy, BillingAccountRegistrationPolicyGateway)
|
|
assert isinstance(email_registration._registration, AccountLifecycleRegistrationGateway)
|
|
assert email_registration._registration._accounts is services.accounts.lifecycle
|
|
assert services.workspaces.invitations._accounts is services.accounts.lifecycle
|
|
assert services.workspaces.invitations._members is services.workspaces.members
|
|
assert services.workspaces.invitations._workspaces is services.workspaces.management._workspaces
|
|
assert services.workspaces.provisioning._members is services.workspaces.members
|
|
assert services.accounts.lifecycle._workspaces is services.workspaces.provisioning
|
|
assert services.workspaces.identity._workspaces is services.workspaces.management._workspaces
|
|
assert services.accounts.education._accounts is accounts
|
|
assert services.accounts.deletion._accounts is accounts
|
|
assert services.accounts.authentication._accounts is accounts
|
|
assert services.accounts.authentication._workspaces is services.workspaces.queries._workspaces
|
|
assert services.step_by_step_tour._accounts is accounts
|
|
assert services.accounts.deletion._memberships is services.workspaces.queries._workspaces
|
|
integrations = services.accounts.integrations._integrations
|
|
assert isinstance(integrations, SQLAlchemyAccountIntegrationRepository)
|
|
assert integrations._session_factory is sqlite_session_factory
|
|
oauth = services.accounts.oauth
|
|
assert oauth._accounts is accounts
|
|
assert oauth._integrations is integrations
|
|
assert oauth._memberships is services.workspaces.queries._workspaces
|
|
assert isinstance(oauth._invitations, AccountActivationOAuthInvitationGateway)
|
|
assert isinstance(oauth._account_claims, RedisOAuthAccountClaimLock)
|
|
assert isinstance(oauth._registration, AccountLifecycleOAuthRegistrationGateway)
|
|
assert isinstance(oauth._workspaces, WorkspaceProvisioningOAuthGateway)
|
|
assert isinstance(oauth._sessions, AccountLifecycleOAuthSessionGateway)
|
|
assert oauth._sessions is not oauth._workspaces
|
|
assert isinstance(oauth._registration_policy, DeploymentOAuthPolicyGateway)
|
|
assert oauth._workspace_policy is oauth._registration_policy
|
|
avatar_files = services.accounts.avatar._files
|
|
assert isinstance(avatar_files, SQLAlchemyAccountAvatarFileGateway)
|
|
assert avatar_files._session_factory is sqlite_session_factory
|
|
|
|
|
|
def test_build_application_services_requires_invitation_for_cloud_initialization(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.CLOUD,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
|
|
assert services.accounts.initialization._invitation_required
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("deployment_edition", "billing_enabled"),
|
|
[
|
|
pytest.param(DeploymentEdition.CLOUD, True, id="cloud"),
|
|
pytest.param(DeploymentEdition.COMMUNITY, False, id="community"),
|
|
pytest.param(DeploymentEdition.ENTERPRISE, False, id="enterprise"),
|
|
],
|
|
)
|
|
def test_build_application_services_wires_account_activation(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
deployment_edition: DeploymentEdition,
|
|
billing_enabled: bool,
|
|
) -> None:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=deployment_edition,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
|
|
activation = services.accounts.activation
|
|
assert isinstance(activation._tokens, RedisInvitationTokenStore)
|
|
assert isinstance(activation._accounts, SQLAlchemyAccountActivationRepository)
|
|
assert activation._accounts._session_factory is sqlite_session_factory
|
|
assert isinstance(activation._workspace_policy, DeploymentWorkspaceInvitePolicy)
|
|
assert isinstance(activation._eligibility, BillingAccountActivationEligibility)
|
|
assert activation._eligibility._enabled is billing_enabled
|
|
assert isinstance(activation._membership_cache, BillingWorkspaceMembershipCache)
|
|
assert activation._membership_cache._enabled is billing_enabled
|
|
assert isinstance(activation._member_access_sync, RBACWorkspaceMemberAccessSync)
|
|
|
|
|
|
def test_build_application_services_wires_data_source_api_key_auth(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.data_sources.api_key_auth, DataSourceApiKeyAuthService)
|
|
|
|
|
|
def test_build_application_services_groups_dataset_services_and_reuses_repositories(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert isinstance(services.data_sources.bindings, DataSourceBindingApplicationService)
|
|
assert isinstance(services.data_sources.notion_imports, NotionImportApplicationService)
|
|
assert isinstance(services.knowledge.document_sync, DocumentSyncApplicationService)
|
|
assert isinstance(services.knowledge.documents, DatasetDocumentApplicationService)
|
|
assert isinstance(services.knowledge.datasets, DatasetApplicationService)
|
|
assert isinstance(services.knowledge.external, ExternalKnowledgeApplicationService)
|
|
assert services.knowledge.datasets._dataset_access is services.knowledge.document_sync._dataset_access
|
|
assert services.knowledge.external._dataset_access is services.knowledge.document_sync._dataset_access
|
|
assert services.knowledge.documents._dataset_access is services.knowledge.document_sync._dataset_access
|
|
assert isinstance(services.knowledge.indexing_estimates, IndexingEstimateApplicationService)
|
|
assert isinstance(services.knowledge.segments, DatasetSegmentApplicationService)
|
|
assert services.data_sources.bindings._bindings is services.data_sources.oauth["notion"]._bindings
|
|
assert services.data_sources.notion_imports._dataset_access is services.knowledge.document_sync._dataset_access
|
|
assert services.data_sources.notion_imports._dataset_access is services.knowledge.indexing_estimates._dataset_access
|
|
assert services.data_sources.notion_imports._documents is services.knowledge.document_sync._documents
|
|
assert services.data_sources.notion_imports._documents is services.knowledge.indexing_estimates._documents
|
|
notion_source = services.data_sources.notion_imports._source
|
|
estimate_service = services.knowledge.indexing_estimates
|
|
dataset_access = services.data_sources.notion_imports._dataset_access
|
|
assert isinstance(notion_source, PluginNotionSourceGateway)
|
|
assert isinstance(estimate_service._runner, IndexingEstimateAdapter)
|
|
assert isinstance(estimate_service._process_rules, SQLAlchemyProcessRuleReader)
|
|
assert isinstance(dataset_access, DatasetAccessService)
|
|
notion_resolver = estimate_service._notion
|
|
assert isinstance(notion_resolver, NotionSourceResolver)
|
|
actor_credentials = notion_source._credentials
|
|
assert isinstance(actor_credentials, ActorAwareDatasourceCredentialGateway)
|
|
assert actor_credentials is notion_resolver._actor_credentials
|
|
assert services.data_sources.providers._credentials is actor_credentials._credentials
|
|
assert services.knowledge.pipeline_generator._datasource_providers is services.data_sources.providers
|
|
assert notion_resolver._stored_credentials is not notion_resolver._actor_credentials
|
|
assert services.workspaces.member_queries._members is dataset_access._workspace_roles
|
|
|
|
|
|
def test_build_application_services_wires_credential_query(
|
|
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=_redis(),
|
|
)
|
|
tenant_id, actor_id = str(uuid4()), str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
credential = ProviderCredential(
|
|
tenant_id=tenant_id, provider_name="openai", credential_name="Team", encrypted_config="encrypted"
|
|
)
|
|
session.add(credential)
|
|
credential_id = credential.id
|
|
|
|
records = services.credential_queries.list_models(workspace_id=tenant_id, provider="openai", actor_id=actor_id)
|
|
|
|
assert [(record.id, record.name) for record in records] == [(credential_id, "Team")]
|
|
|
|
|
|
def test_build_application_services_uses_supplied_redis_for_both_workflow_stop_signals(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
redis = _StopRedis(read_error=AssertionError("Workflow stop must not inspect task ownership"))
|
|
script = MagicMock(side_effect=AssertionError("Workflow stop must not execute Redis scripts"))
|
|
with patch.object(redis, "register_script", return_value=script):
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=redis,
|
|
)
|
|
|
|
services.app_tasks.stop_workflow_task_no_user_check(task_id="workflow-task")
|
|
|
|
assert redis.reads == []
|
|
assert redis.operations == ["legacy_flag", "graph_command"]
|
|
assert redis.values["generate_task_stopped:workflow-task"] == b"1"
|
|
assert redis.expirations["generate_task_stopped:workflow-task"] == 600
|
|
assert [json.loads(command) for command in redis.commands["workflow:workflow-task:commands"]] == [
|
|
{"command_type": "abort", "payload": None, "reason": "User requested stop"}
|
|
]
|
|
assert redis.expirations["workflow:workflow-task:commands"] == 3600
|
|
|
|
|
|
def test_build_application_services_wires_trial_app_usage(
|
|
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=_redis(),
|
|
)
|
|
app_id = str(uuid4())
|
|
account_id = str(uuid4())
|
|
|
|
services.trial_apps.usage.record(app_id=app_id, account_id=account_id)
|
|
|
|
with sqlite_session_factory() as session:
|
|
record = session.scalar(
|
|
select(AccountTrialAppRecord).where(
|
|
AccountTrialAppRecord.app_id == app_id,
|
|
AccountTrialAppRecord.account_id == account_id,
|
|
)
|
|
)
|
|
assert record is not None
|
|
assert record.count == 1
|
|
|
|
|
|
@pytest.fixture
|
|
def installed_app_ref(sqlite_session_factory: sessionmaker[Session]) -> InstalledAppRef:
|
|
with sqlite_session_factory.begin() as session:
|
|
app = App(
|
|
tenant_id=str(uuid4()),
|
|
name="Installed app",
|
|
mode=AppMode.COMPLETION,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
)
|
|
session.add(app)
|
|
session.flush()
|
|
installed_app = InstalledApp(
|
|
tenant_id=str(uuid4()),
|
|
app_id=app.id,
|
|
app_owner_tenant_id=app.tenant_id,
|
|
is_pinned=False,
|
|
)
|
|
session.add(installed_app)
|
|
session.flush()
|
|
result = InstalledAppRef(
|
|
id=installed_app.id,
|
|
app_id=app.id,
|
|
tenant_id=installed_app.tenant_id,
|
|
app_owner_tenant_id=app.tenant_id,
|
|
app_mode=app.mode.value,
|
|
)
|
|
return result
|
|
|
|
|
|
def test_build_application_services_reuses_installed_app_generation_dependencies(
|
|
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=_redis(),
|
|
)
|
|
|
|
assert services.installed_apps.access._installed_apps is services.installed_apps.generation._usage
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("deployment_edition", "permission_result"),
|
|
[
|
|
pytest.param(DeploymentEdition.COMMUNITY, False, id="community-skips-permission"),
|
|
pytest.param(DeploymentEdition.CLOUD, False, id="cloud-skips-permission"),
|
|
pytest.param(DeploymentEdition.ENTERPRISE, True, id="enterprise-allowed"),
|
|
pytest.param(DeploymentEdition.ENTERPRISE, False, id="enterprise-denied"),
|
|
],
|
|
)
|
|
def test_build_application_services_wires_installed_app_admission(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
installed_app_ref: InstalledAppRef,
|
|
deployment_edition: DeploymentEdition,
|
|
permission_result: bool,
|
|
) -> None:
|
|
account_id = str(uuid4())
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request",
|
|
return_value={"result": permission_result},
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=deployment_edition,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
if deployment_edition == DeploymentEdition.ENTERPRISE and not permission_result:
|
|
with pytest.raises(InstalledAppAccessDeniedError):
|
|
services.installed_apps.access.get_access(
|
|
installed_app_id=installed_app_ref.id,
|
|
tenant_id=installed_app_ref.tenant_id,
|
|
account_id=account_id,
|
|
)
|
|
else:
|
|
assert (
|
|
services.installed_apps.access.get_access(
|
|
installed_app_id=installed_app_ref.id,
|
|
tenant_id=installed_app_ref.tenant_id,
|
|
account_id=account_id,
|
|
)
|
|
== installed_app_ref
|
|
)
|
|
|
|
if deployment_edition == DeploymentEdition.ENTERPRISE:
|
|
enterprise_request.assert_called_once_with(
|
|
"GET", "/webapp/permission", params={"userId": account_id, "appId": installed_app_ref.app_id}
|
|
)
|
|
else:
|
|
enterprise_request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"enterprise_error",
|
|
[
|
|
pytest.param(EnterpriseAPINotFoundError(), id="not-found"),
|
|
pytest.param(EnterpriseAPIError("permission unavailable"), id="api-error"),
|
|
pytest.param(httpx.ConnectError("connection failed"), id="transport"),
|
|
pytest.param(json.JSONDecodeError("invalid", "", 0), id="invalid-json"),
|
|
pytest.param(UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), id="invalid-encoding"),
|
|
],
|
|
)
|
|
def test_installed_app_admission_normalizes_known_enterprise_errors(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
installed_app_ref: InstalledAppRef,
|
|
enterprise_error: Exception,
|
|
) -> None:
|
|
account_id = str(uuid4())
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request",
|
|
side_effect=enterprise_error,
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
with pytest.raises(WebAppAccessUnavailableError) as raised:
|
|
services.installed_apps.access.get_access(
|
|
installed_app_id=installed_app_ref.id,
|
|
tenant_id=installed_app_ref.tenant_id,
|
|
account_id=account_id,
|
|
)
|
|
|
|
assert raised.value.__cause__ is enterprise_error
|
|
enterprise_request.assert_called_once_with(
|
|
"GET", "/webapp/permission", params={"userId": account_id, "appId": installed_app_ref.app_id}
|
|
)
|
|
|
|
|
|
def test_trial_generation_uses_configured_access_runtime_and_usage(
|
|
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=_redis(),
|
|
)
|
|
app_id, tenant_id, account_id = str(uuid4()), str(uuid4()), str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(
|
|
App(id=app_id, tenant_id=tenant_id, name="Trial", mode="completion", enable_site=True, enable_api=False)
|
|
)
|
|
account = Account(name="Account", email="trial@example.com")
|
|
account.id = account_id
|
|
session.add(account)
|
|
session.add(TrialApp(app_id=app_id, tenant_id=tenant_id))
|
|
|
|
admitted = services.trial_apps.access.get_access(app_id=app_id, account_id=account_id)
|
|
with patch.object(AppGenerateService, "generate", return_value={"answer": "hello"}):
|
|
response = services.trial_apps.generation.generate_completion(
|
|
trial_app=admitted, account_id=account_id, args={"inputs": {}}
|
|
)
|
|
|
|
assert response == {"answer": "hello"}
|
|
with sqlite_session_factory() as session:
|
|
record = session.scalar(
|
|
select(AccountTrialAppRecord).where(
|
|
AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id
|
|
)
|
|
)
|
|
assert record is not None
|
|
assert record.count == 1
|
|
|
|
|
|
def test_app_audio_uses_the_configured_database_and_app_owner(
|
|
sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
app_id, tenant_id, account_id = str(uuid4()), str(uuid4()), str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
config = AppModelConfig(app_id=app_id, speech_to_text='{"enabled":true}')
|
|
session.add(config)
|
|
session.flush()
|
|
session.add(
|
|
App(
|
|
id=app_id,
|
|
tenant_id=tenant_id,
|
|
name="Trial",
|
|
mode="chat",
|
|
app_model_config_id=config.id,
|
|
enable_site=True,
|
|
enable_api=False,
|
|
)
|
|
)
|
|
session.add(TrialApp(app_id=app_id, tenant_id=tenant_id))
|
|
|
|
def transcribe(*, app: AudioAppRef, content: bytes, end_user: str | None) -> str:
|
|
assert app == AudioAppRef(app_id=app_id, tenant_id=tenant_id, app_mode="chat")
|
|
assert content == b"audio"
|
|
assert end_user is None
|
|
return "transcript"
|
|
|
|
def synthesize(*, app: AudioAppRef, text: str, voice: str | None, end_user: str | None) -> AudioOutput:
|
|
assert app == AudioAppRef(app_id=app_id, tenant_id=tenant_id, app_mode="chat")
|
|
assert (text, voice, end_user) == ("read", "voice", None)
|
|
return AudioOutput(data=b"audio", mime_type="audio/mpeg")
|
|
|
|
monkeypatch.setattr(audio_provider_gateway, "speech_to_text", transcribe)
|
|
monkeypatch.setattr(audio_provider_gateway, "text_to_speech", synthesize)
|
|
admitted = services.trial_apps.access.get_access(app_id=app_id, account_id=account_id)
|
|
assert services.app_audio.transcript_asr(
|
|
app=AudioAppRef(admitted.app_id, admitted.tenant_id, admitted.app_mode),
|
|
audio=AudioUpload(stream=BytesIO(b"audio"), mime_type="audio/mp3"),
|
|
) == {"text": "transcript"}
|
|
assert services.app_audio.transcript_tts(
|
|
app=AudioAppRef(admitted.app_id, admitted.tenant_id, admitted.app_mode),
|
|
account_id=account_id,
|
|
text=" read ",
|
|
voice="voice",
|
|
message_id=None,
|
|
) == AudioOutput(data=b"audio", mime_type="audio/mpeg")
|
|
|
|
|
|
def test_app_previews_use_the_configured_catalog_and_app_owner(
|
|
sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin")
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
app_id, tenant_id, other_id = str(uuid4()), str(uuid4()), str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
App(id=app_id, tenant_id=tenant_id, name="Preview", mode="chat", enable_site=False, enable_api=False),
|
|
App(id=other_id, tenant_id=tenant_id, name="Private", mode="chat", enable_site=False, enable_api=False),
|
|
]
|
|
)
|
|
|
|
# Catalog-only previews must work without a Trial registration or account.
|
|
payload = json.dumps({"app_details": {app_id: {"id": app_id}}})
|
|
with patch.object(recommended_app_catalog_gateway.Path, "read_text", return_value=payload):
|
|
assert services.app_previews.get_access(app_id=app_id) == AppPreviewRef(app_id=app_id, tenant_id=tenant_id)
|
|
with pytest.raises(AppPreviewUnavailableError, match=other_id):
|
|
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=_redis(),
|
|
)
|
|
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:
|
|
with (
|
|
patch("extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True),
|
|
patch(
|
|
"extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
|
return_value=SimpleNamespace(access_mode="private_all"),
|
|
) as get_access_mode,
|
|
):
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
result = services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
|
|
|
|
assert result is WebAppAccessMode.PRIVATE_ALL
|
|
get_access_mode.assert_called_once_with("app-1")
|
|
|
|
|
|
def _query_webapp_access(
|
|
service: WebAppAccessQueryService, query_kind: str
|
|
) -> WebAppAccessMode | bool | Mapping[str, WebAppAccessMode] | Mapping[str, bool]:
|
|
if query_kind == "single-mode":
|
|
return service.get_access_mode(app_id="app-1", app_code=None)
|
|
if query_kind == "single-permission":
|
|
return service.is_user_allowed(user_id="viewer", app_id="app-1")
|
|
if query_kind == "batch-modes":
|
|
return service.batch_get_access_modes(app_ids=("app-1",))
|
|
assert query_kind == "batch-permissions"
|
|
return service.batch_get_user_permissions(user_id="viewer", app_ids=("app-1",))
|
|
|
|
|
|
@pytest.mark.parametrize("query_kind", ["single-mode", "single-permission", "batch-modes", "batch-permissions"])
|
|
@pytest.mark.parametrize(
|
|
"enterprise_error",
|
|
[
|
|
pytest.param(httpx.ReadTimeout("Enterprise timed out"), id="timeout"),
|
|
pytest.param(httpx.ConnectError("connection failed"), id="connection"),
|
|
pytest.param(EnterpriseServiceError("upstream failure"), id="upstream"),
|
|
pytest.param(json.JSONDecodeError("invalid", "", 0), id="invalid-json"),
|
|
pytest.param(UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), id="invalid-encoding"),
|
|
],
|
|
)
|
|
def test_webapp_access_queries_map_known_enterprise_errors_to_unavailable(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
query_kind: str,
|
|
enterprise_error: Exception,
|
|
) -> None:
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request", side_effect=enterprise_error
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
with pytest.raises(WebAppAccessUnavailableError) as raised:
|
|
_query_webapp_access(services.webapp_access, query_kind)
|
|
|
|
assert type(raised.value) is WebAppAccessUnavailableError
|
|
assert raised.value.__cause__ is enterprise_error
|
|
assert enterprise_request.call_count == 1
|
|
|
|
|
|
@pytest.mark.parametrize("access_mode", ["invalid", 123])
|
|
def test_single_webapp_mode_maps_invalid_enum_or_field_value_to_unavailable(
|
|
sqlite_session_factory: sessionmaker[Session], access_mode: str | int
|
|
) -> None:
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request",
|
|
return_value={"accessMode": access_mode},
|
|
):
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
with pytest.raises(WebAppAccessUnavailableError) as raised:
|
|
services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
|
|
|
|
assert type(raised.value) is WebAppAccessUnavailableError
|
|
assert isinstance(raised.value.__cause__, ValueError)
|
|
|
|
|
|
@pytest.mark.parametrize("query_kind", ["single-mode", "single-permission", "batch-modes", "batch-permissions"])
|
|
@pytest.mark.parametrize("failure", [TypeError("adapter bug"), ValueError("unexpected programming error")])
|
|
def test_webapp_access_queries_do_not_hide_unknown_programming_errors(
|
|
sqlite_session_factory: sessionmaker[Session], query_kind: str, failure: Exception
|
|
) -> None:
|
|
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request", side_effect=failure):
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
with pytest.raises(type(failure)) as raised:
|
|
_query_webapp_access(services.webapp_access, query_kind)
|
|
|
|
assert raised.value is failure
|
|
|
|
|
|
def test_build_application_services_wires_webapp_permission(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
with (
|
|
patch(
|
|
"extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True
|
|
) as enabled,
|
|
patch(
|
|
"extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
|
return_value=SimpleNamespace(access_mode="private"),
|
|
) as get_access_mode,
|
|
patch(
|
|
"extensions.ext_application_services.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp",
|
|
return_value=False,
|
|
) as is_user_allowed,
|
|
):
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
requires_permission = services.webapp_access.requires_permission_check("app-1")
|
|
allowed = services.webapp_access.is_user_allowed(user_id="user-1", app_id="app-1")
|
|
|
|
assert requires_permission is True
|
|
assert allowed is False
|
|
enabled.assert_has_calls(
|
|
[
|
|
call(deployment_edition=DeploymentEdition.COMMUNITY),
|
|
call(deployment_edition=DeploymentEdition.COMMUNITY),
|
|
]
|
|
)
|
|
get_access_mode.assert_called_once_with("app-1")
|
|
is_user_allowed.assert_called_once_with("user-1", "app-1")
|
|
|
|
|
|
def test_webapp_permission_adapter_maps_connection_failure() -> None:
|
|
failure = httpx.ConnectError("connection failed")
|
|
adapter = EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseService.WebAppAuth)
|
|
with (
|
|
patch(
|
|
"extensions.ext_application_services.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp",
|
|
side_effect=failure,
|
|
),
|
|
pytest.raises(WebAppAccessUnavailableError) as raised,
|
|
):
|
|
adapter.is_user_allowed(user_id="user-1", app_id="app-1")
|
|
|
|
assert raised.value.__cause__ is failure
|
|
|
|
|
|
def test_build_application_services_wires_dynamic_recommended_catalog(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin")
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
|
|
builtin_payload = json.dumps(
|
|
{
|
|
"recommended_apps": {
|
|
"en-US": {
|
|
"recommended_apps": [{"app": None, "app_id": "app-1", "categories": []}],
|
|
"categories": [],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
with patch.object(recommended_app_catalog_gateway.Path, "read_text", return_value=builtin_payload):
|
|
result = services.recommended_app_queries.list_recommended(
|
|
language="en-US",
|
|
)
|
|
assert result.recommended_apps
|
|
|
|
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="invalid")
|
|
with pytest.raises(ValueError, match="invalid fetch recommended apps mode: invalid"):
|
|
services.recommended_app_queries.list_recommended(
|
|
language="en-US",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("deployment_edition", "permission_result"),
|
|
[
|
|
pytest.param(DeploymentEdition.COMMUNITY, False, id="community-skips-enterprise"),
|
|
pytest.param(DeploymentEdition.CLOUD, False, id="cloud-skips-enterprise"),
|
|
pytest.param(DeploymentEdition.ENTERPRISE, True, id="enterprise-allowed"),
|
|
pytest.param(DeploymentEdition.ENTERPRISE, False, id="enterprise-denied"),
|
|
],
|
|
)
|
|
def test_installed_app_management_composition_reads_real_installations_and_current_workspace_role(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
installed_app_ref: InstalledAppRef,
|
|
deployment_edition: DeploymentEdition,
|
|
permission_result: bool,
|
|
) -> None:
|
|
account_id = str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
account = Account(name="Viewer", email="management@example.com")
|
|
account.id = account_id
|
|
tenant = Tenant(name="Viewer workspace")
|
|
tenant.id = installed_app_ref.tenant_id
|
|
session.add_all([account, tenant])
|
|
membership = TenantAccountJoin(
|
|
tenant_id=installed_app_ref.tenant_id, account_id=account_id, role=TenantAccountRole.OWNER
|
|
)
|
|
configuration = AppModelConfig(app_id=installed_app_ref.app_id)
|
|
session.add_all([membership, configuration])
|
|
session.flush()
|
|
app = session.get(App, installed_app_ref.app_id)
|
|
assert app is not None
|
|
app.app_model_config_id = configuration.id
|
|
membership_id = membership.id
|
|
|
|
active_connections = 0
|
|
engine = sqlite_session_factory.kw["bind"]
|
|
|
|
@event.listens_for(engine, "checkout")
|
|
def connection_checked_out(_connection: object, _record: object, _proxy: object) -> None:
|
|
nonlocal active_connections
|
|
active_connections += 1
|
|
|
|
@event.listens_for(engine, "checkin")
|
|
def connection_checked_in(_connection: object, _record: object) -> None:
|
|
nonlocal active_connections
|
|
active_connections -= 1
|
|
|
|
def enterprise_response(method: str, path: str, *, json: dict[str, object]) -> dict[str, object]:
|
|
assert deployment_edition == DeploymentEdition.ENTERPRISE
|
|
# The candidate read must release its DB connection before either network call.
|
|
assert active_connections == 0
|
|
assert method == "POST"
|
|
if path == "/webapp/access-mode/batch/id":
|
|
assert json == {"appIds": [installed_app_ref.app_id]}
|
|
return {"accessModes": {installed_app_ref.app_id: "private"}}
|
|
assert path == "/webapp/permission/batch"
|
|
assert json == {"userId": account_id, "appIds": [installed_app_ref.app_id]}
|
|
return {"permissions": {installed_app_ref.app_id: permission_result}}
|
|
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request", side_effect=enterprise_response
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=deployment_edition,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
if deployment_edition != DeploymentEdition.ENTERPRISE:
|
|
assert services.webapp_access.batch_get_access_modes(app_ids=(installed_app_ref.app_id,)) == {
|
|
installed_app_ref.app_id: WebAppAccessMode.PUBLIC
|
|
}
|
|
assert services.webapp_access.batch_get_user_permissions(
|
|
user_id=account_id, app_ids=(installed_app_ref.app_id,)
|
|
) == {installed_app_ref.app_id: True}
|
|
assert services.installed_apps.access.get_visible_app_ids(
|
|
user_id=account_id, app_ids=(installed_app_ref.app_id,)
|
|
) == frozenset({installed_app_ref.app_id})
|
|
page = services.installed_apps.management.get_visible_page(
|
|
tenant_id=installed_app_ref.tenant_id,
|
|
user_id=account_id,
|
|
cursor=None,
|
|
limit=1,
|
|
app_id=None,
|
|
name=None,
|
|
)
|
|
assert page.editable is True
|
|
assert page.has_more is False
|
|
assert page.next_cursor is None
|
|
expected_ids: list[str] = (
|
|
[installed_app_ref.id] if deployment_edition != DeploymentEdition.ENTERPRISE or permission_result else []
|
|
)
|
|
assert [installation.id for installation in page.data] == expected_ids
|
|
|
|
with sqlite_session_factory.begin() as session:
|
|
stored_membership = session.get(TenantAccountJoin, membership_id)
|
|
assert stored_membership is not None
|
|
stored_membership.role = TenantAccountRole.NORMAL
|
|
services.installed_apps.management.set_pinned(installed_app=installed_app_ref, is_pinned=True)
|
|
detail = services.installed_apps.management.get_detail(installed_app=installed_app_ref, account_id=account_id)
|
|
assert detail.editable is False
|
|
assert detail.installation.is_pinned is True
|
|
assert detail.installation.id == installed_app_ref.id
|
|
assert detail.installation.app.id == installed_app_ref.app_id
|
|
assert active_connections == 0
|
|
|
|
if deployment_edition == DeploymentEdition.ENTERPRISE:
|
|
assert enterprise_request.call_args_list == [
|
|
call("POST", "/webapp/access-mode/batch/id", json={"appIds": [installed_app_ref.app_id]}),
|
|
call(
|
|
"POST",
|
|
"/webapp/permission/batch",
|
|
json={"userId": account_id, "appIds": [installed_app_ref.app_id]},
|
|
),
|
|
]
|
|
else:
|
|
enterprise_request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("naming_fails", [False, True])
|
|
def test_installed_app_conversations_wire_real_persistence_naming_and_cleanup(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
installed_app_ref: InstalledAppRef,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
naming_fails: bool,
|
|
) -> None:
|
|
account_id = str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
app = session.get(App, installed_app_ref.app_id)
|
|
assert app is not None
|
|
app.mode = AppMode.CHAT
|
|
owner_tenant_id = app.tenant_id
|
|
conversation = Conversation(
|
|
app_id=app.id,
|
|
mode=AppMode.CHAT,
|
|
name="Original",
|
|
inputs={"enabled": False},
|
|
from_source="console",
|
|
from_account_id=account_id,
|
|
from_end_user_id=None,
|
|
invoke_from="explore",
|
|
)
|
|
session.add(conversation)
|
|
session.flush()
|
|
conversation_id = conversation.id
|
|
session.add(
|
|
Message(
|
|
app_id=app.id,
|
|
conversation_id=conversation.id,
|
|
inputs={},
|
|
query="First question",
|
|
message={},
|
|
message_unit_price=Decimal(0),
|
|
answer="Answer",
|
|
answer_unit_price=Decimal(0),
|
|
currency="USD",
|
|
from_source="console",
|
|
from_account_id=account_id,
|
|
)
|
|
)
|
|
|
|
active_connections = 0
|
|
|
|
@event.listens_for(sqlite_session_factory.kw["bind"], "checkout")
|
|
def connection_checked_out(_connection: object, _record: object, _proxy: object) -> None:
|
|
nonlocal active_connections
|
|
active_connections += 1
|
|
|
|
@event.listens_for(sqlite_session_factory.kw["bind"], "checkin")
|
|
def connection_checked_in(_connection: object, _record: object) -> None:
|
|
nonlocal active_connections
|
|
active_connections -= 1
|
|
|
|
naming_sessions: list[Session] = []
|
|
|
|
def generate_name(tenant_id: str, query: str, c_id: str, app_id: str) -> str:
|
|
assert active_connections == 0
|
|
assert get_credit_usage_metadata() == {"app_type": CreditUsageAppType.CHATBOT, "request_id": "naming-request"}
|
|
assert (tenant_id, query, c_id, app_id) == (
|
|
owner_tenant_id,
|
|
"First question",
|
|
conversation_id,
|
|
installed_app_ref.app_id,
|
|
)
|
|
# Provider/tracing code still uses the Flask-scoped session. Exercise
|
|
# that lifecycle rather than letting a pure callback hide a leaked read.
|
|
naming_session = db.session()
|
|
naming_session.scalar(select(1))
|
|
naming_sessions.append(naming_session)
|
|
if naming_fails:
|
|
raise RuntimeError("Provider unavailable")
|
|
return "Generated name"
|
|
|
|
queued: list[str] = []
|
|
|
|
def enqueue_cleanup(c_id: str) -> None:
|
|
assert active_connections == 0
|
|
assert all(not session.in_transaction() for session in naming_sessions)
|
|
with sqlite_session_factory() as session:
|
|
stored = session.get(Conversation, c_id)
|
|
assert stored is not None
|
|
assert stored.is_deleted
|
|
queued.append(c_id)
|
|
|
|
monkeypatch.setattr(LLMGenerator, "generate_conversation_name", generate_name)
|
|
monkeypatch.setattr(delete_conversation_related_data, "delay", enqueue_cleanup)
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.COMMUNITY,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
naming_app = Flask(__name__)
|
|
admitted_app = services.installed_apps.access.get_access(
|
|
installed_app_id=installed_app_ref.id,
|
|
tenant_id=installed_app_ref.tenant_id,
|
|
account_id=account_id,
|
|
)
|
|
assert admitted_app.app_mode == "chat"
|
|
naming_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://"
|
|
db.init_app(naming_app)
|
|
previous_metadata = get_credit_usage_metadata()
|
|
with naming_app.app_context(), use_credit_usage_metadata({"request_id": "naming-request"}):
|
|
outer_session = db.session()
|
|
renamed = services.installed_apps.conversations.rename(
|
|
installed_app=admitted_app,
|
|
account_id=account_id,
|
|
conversation_id=conversation_id,
|
|
name=None,
|
|
auto_generate=True,
|
|
)
|
|
assert get_credit_usage_metadata() == {"request_id": "naming-request"}
|
|
assert renamed.name == ("Original" if naming_fails else "Generated name")
|
|
assert renamed.inputs == {"enabled": False}
|
|
assert len(naming_sessions) == 1
|
|
assert naming_sessions[0] is not outer_session
|
|
assert not naming_sessions[0].in_transaction()
|
|
assert db.session() is outer_session
|
|
services.installed_apps.conversations.delete(
|
|
installed_app=admitted_app, account_id=account_id, conversation_id=conversation_id
|
|
)
|
|
assert get_credit_usage_metadata() == previous_metadata
|
|
assert queued == [conversation_id]
|
|
assert active_connections == 0
|
|
|
|
|
|
def test_installed_app_visibility_batches_settings_before_permissions_and_preserves_truthiness_filter(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
app_ids = (
|
|
"allowed",
|
|
"truthy-permission",
|
|
"missing-setting",
|
|
"sso",
|
|
"denied",
|
|
"zero",
|
|
"empty",
|
|
"null",
|
|
"missing-permission",
|
|
)
|
|
permission_candidates = ["allowed", "truthy-permission", "denied", "zero", "empty", "null", "missing-permission"]
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request",
|
|
side_effect=[
|
|
{
|
|
"accessModes": {
|
|
"allowed": "private",
|
|
"truthy-permission": "private_all",
|
|
"sso": "sso_verified",
|
|
"denied": "public",
|
|
"zero": "private_all",
|
|
"empty": "public",
|
|
"null": "private",
|
|
"missing-permission": "private",
|
|
}
|
|
},
|
|
{
|
|
"permissions": {
|
|
"allowed": True,
|
|
"truthy-permission": 1,
|
|
"missing-setting": True,
|
|
"sso": True,
|
|
"denied": False,
|
|
"zero": 0,
|
|
"empty": "",
|
|
"null": None,
|
|
}
|
|
},
|
|
],
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
visible = services.installed_apps.access.get_visible_app_ids(user_id="viewer", app_ids=app_ids)
|
|
|
|
assert visible == frozenset({"allowed", "truthy-permission"})
|
|
assert enterprise_request.call_args_list == [
|
|
call("POST", "/webapp/access-mode/batch/id", json={"appIds": list(app_ids)}),
|
|
call("POST", "/webapp/permission/batch", json={"userId": "viewer", "appIds": permission_candidates}),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("include_valid_apps", [True, False], ids=["mixed-modes", "all-invalid"])
|
|
def test_installed_app_visibility_skips_and_logs_each_invalid_access_mode(
|
|
sqlite_session_factory: sessionmaker[Session], caplog: pytest.LogCaptureFixture, include_valid_apps: bool
|
|
) -> None:
|
|
modes = {"invalid-empty": "", "invalid-unknown": "future-mode"}
|
|
valid_ids: list[str] = ["valid-before", "valid-after"] if include_valid_apps else []
|
|
if include_valid_apps:
|
|
modes = {"valid-before": "private", **modes, "valid-after": "public"}
|
|
app_ids = tuple(modes)
|
|
responses: list[object] = [{"accessModes": modes}]
|
|
if include_valid_apps:
|
|
# Even an over-inclusive permission response must not restore invalid apps.
|
|
responses.append({"permissions": dict.fromkeys(app_ids, True)})
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request", side_effect=responses
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
visible = services.installed_apps.access.get_visible_app_ids(user_id="viewer", app_ids=app_ids)
|
|
|
|
assert visible == frozenset(valid_ids)
|
|
expected_calls = [call("POST", "/webapp/access-mode/batch/id", json={"appIds": list(app_ids)})]
|
|
if include_valid_apps:
|
|
expected_calls.append(call("POST", "/webapp/permission/batch", json={"userId": "viewer", "appIds": valid_ids}))
|
|
assert enterprise_request.call_args_list == expected_calls
|
|
warnings = [
|
|
message
|
|
for logger_name, level, message in caplog.record_tuples
|
|
if logger_name == ext_application_services.__name__ and level == logging.WARNING
|
|
]
|
|
assert len(warnings) == 2
|
|
assert any("invalid-empty" in message and repr("") in message for message in warnings)
|
|
assert any("invalid-unknown" in message and repr("future-mode") in message for message in warnings)
|
|
|
|
|
|
@pytest.mark.parametrize("app_ids", [(), ("sso", "missing")])
|
|
def test_installed_app_visibility_skips_unnecessary_enterprise_requests(
|
|
sqlite_session_factory: sessionmaker[Session], app_ids: tuple[str, ...]
|
|
) -> None:
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request",
|
|
return_value={"accessModes": {"sso": "sso_verified"}},
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
visible = services.installed_apps.access.get_visible_app_ids(user_id="viewer", app_ids=app_ids)
|
|
|
|
assert visible == frozenset()
|
|
if app_ids:
|
|
enterprise_request.assert_called_once_with(
|
|
"POST", "/webapp/access-mode/batch/id", json={"appIds": list(app_ids)}
|
|
)
|
|
else:
|
|
enterprise_request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("failure_stage", ["settings", "permissions"])
|
|
def test_installed_app_visibility_propagates_access_unavailable_with_original_cause(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
failure_stage: str,
|
|
) -> None:
|
|
enterprise_error = EnterpriseAPIError("batch unavailable")
|
|
responses: list[object] = []
|
|
if failure_stage == "permissions":
|
|
responses.append({"accessModes": {"app-1": "private"}})
|
|
responses.append(enterprise_error)
|
|
with patch(
|
|
"services.enterprise.enterprise_service.EnterpriseRequest.send_request", side_effect=responses
|
|
) as enterprise_request:
|
|
services = ext_application_services.build_application_services(
|
|
database_client=sqlite_session_factory,
|
|
deployment_edition=DeploymentEdition.ENTERPRISE,
|
|
initialization_password="",
|
|
redis=_redis(),
|
|
)
|
|
with pytest.raises(WebAppAccessUnavailableError) as caught:
|
|
services.installed_apps.access.get_visible_app_ids(user_id="viewer", app_ids=("app-1",))
|
|
|
|
assert type(caught.value) is WebAppAccessUnavailableError
|
|
assert caught.value.__cause__ is enterprise_error
|
|
assert enterprise_request.call_count == (2 if failure_stage == "permissions" else 1)
|