"""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)