Files
dify/api/tests/unit_tests/extensions/test_ext_application_services.py
T

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)