refactor(api): isolate installed app management (#41957)

This commit is contained in:
非法操作
2026-09-23 09:24:22 +00:00
committed by GitHub
parent 518bd238f5
commit d1391c2ed5
62 changed files with 2280 additions and 1368 deletions
+14
View File
@@ -210,6 +210,20 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:installed-app-management-boundary]
name = Installed app management service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-generation-boundary]
name = Installed app generation service is framework and persistence neutral
type = forbidden
+34
View File
@@ -1,6 +1,40 @@
from core.logging.context import get_request_id
from libs.exception import BaseHTTPException
class InstalledAppHTTPError(BaseHTTPException):
"""A Console error with safe details and a request ID for troubleshooting."""
def __init__(self) -> None:
super().__init__()
details: dict[str, object] = {"request_id": get_request_id()}
self.data = {"code": self.error_code, "message": self.description, "status": self.code, "details": details}
class InstalledAppNotFoundHTTPError(InstalledAppHTTPError):
error_code = "installed_app_not_found"
description = "The app was not found in this workspace."
code = 404
class InstalledAppUnavailableHTTPError(InstalledAppHTTPError):
error_code = "installed_app_unavailable"
description = "The app is not available in this app library."
code = 404
class InstalledAppInvalidCursorError(InstalledAppHTTPError):
error_code = "invalid_cursor"
description = "The app list cursor is invalid. Refresh the list and try again."
code = 400
class WebAppAccessUnavailableHTTPError(InstalledAppHTTPError):
error_code = "web_app_access_unavailable"
description = "The app access service is unavailable. Try again later."
code = 503
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Not Completion App"
+75 -145
View File
@@ -2,37 +2,34 @@ import base64
import binascii
import logging
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field
from sqlalchemy import and_, select
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse
from controllers.common.fields import SimpleResultMessageResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
model_validate,
with_current_tenant_id,
with_current_user,
from controllers.console.explore.error import (
InstalledAppInvalidCursorError,
InstalledAppNotFoundHTTPError,
InstalledAppUnavailableHTTPError,
WebAppAccessUnavailableHTTPError,
)
from extensions.ext_database import db
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account, App, InstalledApp, RecommendedApp
from machinery.context import RequestContext
from models.model import AppMode, IconType
from services.account_service import TenantService
from services.installed_app_service import InstalledAppCursor, InstalledAppService
class InstalledAppCreatePayload(BaseModel):
app_id: str
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_service import (
InstalledAppCursor,
InstalledAppRecord,
InstalledAppUnavailableError,
)
from services.webapp_access_query_service import WebAppAccessUnavailableError
class InstalledAppUpdatePayload(BaseModel):
@@ -75,8 +72,8 @@ def _decode_installed_app_cursor(cursor: str | None) -> InstalledAppCursor | Non
padded_cursor = cursor + "=" * (-len(cursor) % 4)
payload = base64.b64decode(padded_cursor, altchars=b"-_", validate=True)
return InstalledAppCursor.model_validate_json(payload)
except (binascii.Error, UnicodeDecodeError, ValueError):
raise BadRequest("Invalid cursor") from None
except (binascii.Error, UnicodeDecodeError, ValueError) as error:
raise InstalledAppInvalidCursorError() from error
class InstalledAppInfoResponse(ResponseModel):
@@ -102,7 +99,6 @@ class InstalledAppResponse(ResponseModel):
is_pinned: bool
last_used_at: int | None
editable: bool
uninstallable: bool
class InstalledAppListResponse(ResponseModel):
@@ -112,26 +108,22 @@ class InstalledAppListResponse(ResponseModel):
def _installed_app_response_data(
installed_app: InstalledApp,
app_model: App,
installed_app: InstalledAppRecord,
*,
current_tenant_id: str,
current_user: Account,
editable: bool,
) -> InstalledAppResponse:
return InstalledAppResponse(
id=installed_app.id,
app=InstalledAppInfoResponse.model_validate(app_model),
app=InstalledAppInfoResponse.model_validate(installed_app.app, from_attributes=True),
app_owner_tenant_id=installed_app.app_owner_tenant_id,
is_pinned=installed_app.is_pinned,
last_used_at=to_timestamp(installed_app.last_used_at),
editable=current_user.role in {"owner", "admin"},
uninstallable=current_tenant_id == installed_app.app_owner_tenant_id,
editable=editable,
)
register_schema_models(
console_ns,
InstalledAppCreatePayload,
InstalledAppUpdatePayload,
InstalledAppsListQuery,
)
@@ -140,155 +132,93 @@ register_response_schema_models(
InstalledAppInfoResponse,
InstalledAppResponse,
InstalledAppListResponse,
SimpleMessageResponse,
SimpleResultMessageResponse,
)
@console_ns.route("/installed-apps")
class InstalledAppsListApi(Resource):
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(InstalledAppsListQuery))
@console_ns.response(200, "Success", console_ns.models[InstalledAppListResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
@console_account_admission()
@model_validate(InstalledAppsListQuery)
def get(self, query: InstalledAppsListQuery, request_context: RequestContext) -> dict[str, object]:
cursor = _decode_installed_app_cursor(query.cursor)
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
installed_apps, has_more, next_cursor = InstalledAppService.get_visible_page(
tenant_id=current_tenant_id,
user_id=str(current_user.id),
cursor=cursor,
limit=query.limit,
app_id=query.app_id,
name=query.name,
session=db.session,
)
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
try:
page = application_services().installed_apps.get_visible_page(
tenant_id=request_context.active_workspace_id,
user_id=request_context.account_id,
cursor=cursor,
limit=query.limit,
app_id=query.app_id,
name=query.name,
)
except WebAppAccessUnavailableError as error:
logger.exception(
"Installed app list access check failed: workspace_id=%s account_id=%s request_id=%s",
request_context.active_workspace_id,
request_context.account_id,
request_context.request_id,
)
raise WebAppAccessUnavailableHTTPError() from error
installed_app_list = [
_installed_app_response_data(
installed_app,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
editable=page.editable,
)
for installed_app, app_model in installed_apps
for installed_app in page.data
]
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, current_user.id)
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, request_context.account_id)
return dump_response(
InstalledAppListResponse,
{
"installed_apps": installed_app_list,
"has_more": has_more,
"next_cursor": _encode_installed_app_cursor(next_cursor) if next_cursor else None,
"has_more": page.has_more,
"next_cursor": _encode_installed_app_cursor(page.next_cursor) if page.next_cursor else None,
},
)
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@console_ns.expect(console_ns.models[InstalledAppCreatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleMessageResponse.__name__])
@with_current_tenant_id
@model_validate(InstalledAppCreatePayload)
def post(self, req_data: InstalledAppCreatePayload, current_tenant_id: str):
recommended_app = db.session.scalar(
select(RecommendedApp).where(RecommendedApp.app_id == req_data.app_id).limit(1)
)
if recommended_app is None:
raise NotFound("Recommended app not found")
app = db.session.get(App, req_data.app_id)
if app is None:
raise NotFound("App entity not found")
if not app.is_public:
raise Forbidden("You can't install a non-public app")
installed_app = db.session.scalar(
select(InstalledApp)
.where(and_(InstalledApp.app_id == req_data.app_id, InstalledApp.tenant_id == current_tenant_id))
.limit(1)
)
if installed_app is None:
# todo: position
recommended_app.install_count += 1
new_installed_app = InstalledApp(
app_id=req_data.app_id,
tenant_id=current_tenant_id,
app_owner_tenant_id=app.tenant_id,
is_pinned=False,
last_used_at=naive_utc_now(),
)
db.session.add(new_installed_app)
db.session.commit()
return {"message": "App installed successfully"}
@console_ns.route("/installed-apps/<uuid:installed_app_id>")
class InstalledAppApi(InstalledAppResource):
"""
get, update, and delete an installed app
use InstalledAppResource to apply default decorators and get installed_app
"""
class InstalledAppApi(Resource):
"""Read or update an admitted workspace installation."""
@console_ns.response(200, "Success", console_ns.models[InstalledAppResponse.__name__])
@with_current_user
@with_current_tenant_id
@console_account_admission()
@get_installed_app
def get(
self,
current_tenant_id: str,
current_user: Account,
installed_app: InstalledApp,
):
app_model = InstalledAppService.get_published_app(installed_app.app_id, session=db.session)
if app_model is None:
raise NotFound("Installed app not found")
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> dict[str, object]:
try:
detail = application_services().installed_apps.get_detail(
installed_app=installed_app, account_id=request_context.account_id
)
except InstalledAppUnavailableError as error:
raise InstalledAppUnavailableHTTPError() from error
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
return dump_response(
InstalledAppResponse,
_installed_app_response_data(
installed_app,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
detail.installation,
editable=detail.editable,
),
)
@console_ns.response(204, "App uninstalled successfully")
@with_current_tenant_id
def delete(self, current_tenant_id: str, installed_app: InstalledApp):
if installed_app.app_owner_tenant_id == current_tenant_id:
raise BadRequest("You can't uninstall an app owned by the current tenant")
db.session.delete(installed_app)
db.session.commit()
return "", 204
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__])
@console_account_admission()
@get_installed_app
@model_validate(InstalledAppUpdatePayload)
def patch(self, req_data: InstalledAppUpdatePayload, installed_app: InstalledApp):
commit_args = False
if req_data.is_pinned is not None:
installed_app.is_pinned = req_data.is_pinned
commit_args = True
if commit_args:
db.session.commit()
def patch(
self, req_data: InstalledAppUpdatePayload, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, str]:
try:
application_services().installed_apps.set_pinned(installed_app=installed_app, is_pinned=req_data.is_pinned)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
return {"result": "success", "message": "App info updated successfully"}
@@ -1,13 +1,15 @@
"""Installed-app admission for handlers using a pure installation reference."""
import logging
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from werkzeug.exceptions import NotFound
from controllers.console.explore.error import AppAccessDeniedError
from controllers.web.error import WebAppAccessServiceUnavailableError
from controllers.console.explore.error import (
AppAccessDeniedError,
InstalledAppNotFoundHTTPError,
WebAppAccessUnavailableHTTPError,
)
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from services.installed_app_access_service import (
@@ -17,6 +19,8 @@ from services.installed_app_access_service import (
)
from services.webapp_access_query_service import WebAppAccessUnavailableError
logger = logging.getLogger(__name__)
def get_installed_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, InstalledAppRef, P], R],
@@ -27,19 +31,27 @@ def get_installed_app[T, **P, R](
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
installed_app_id = kwargs.pop("installed_app_id", None)
if installed_app_id is None:
raise ValueError("installed_app_id is required")
raise RuntimeError("The installed-app admission route must provide installed_app_id")
try:
installed_app = application_services().installed_app_access.get_access(
installed_app_id=str(installed_app_id),
tenant_id=request_context.active_workspace_id,
account_id=request_context.account_id,
)
except InstalledAppNotFoundError:
raise NotFound("Installed app not found") from None
except InstalledAppAccessDeniedError:
raise AppAccessDeniedError() from None
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
except InstalledAppAccessDeniedError as error:
raise AppAccessDeniedError() from error
except WebAppAccessUnavailableError as error:
logger.exception(
"Installed app admission access check failed: installed_app_id=%s workspace_id=%s "
"account_id=%s request_id=%s",
installed_app_id,
request_context.active_workspace_id,
request_context.account_id,
request_context.request_id,
)
raise WebAppAccessUnavailableHTTPError() from error
return view(self, request_context, installed_app, *args, **kwargs)
return decorated
+4
View File
@@ -651,6 +651,10 @@ def with_current_user_id[T, **P, R](
def validate_request[M: BaseModel](model: type[M]) -> M:
"""Parse and validate the current request without exposing submitted values."""
# TODO: Standardize request parsing and structured validation errors after
# auditing callers. Malformed or falsy JSON currently becomes {}, allowing
# optional payloads to succeed as no-ops; validation details currently travel
# as a JSON string in the error message.
if request.method == "GET":
raw = request.args.to_dict(flat=True)
elif request.method == "DELETE":
+59 -8
View File
@@ -1,8 +1,9 @@
"""Composition root for application services used by transport adapters."""
import json
import logging
import time
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import partial
@@ -183,6 +184,7 @@ from services.inner_mail_service import InnerMailService
from services.installed_app_access_service import InstalledAppAccessService
from services.installed_app_generation_adapters import AppGenerateServiceRuntime as InstalledAppGenerateServiceRuntime
from services.installed_app_generation_service import InstalledAppGenerationService
from services.installed_app_service import InstalledAppService
from services.knowledge.api_key_service import DatasetApiKeyService
from services.message_file_preview_service import MessageFilePreviewService
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
@@ -252,9 +254,20 @@ from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway
from services.workspace_query_service import WorkspaceQueryService
from tasks.mail_inner_task import enqueue_inner_mail
logger = logging.getLogger(__name__)
_EXTENSION_KEY = "application_services"
# TODO: Normalize EnterpriseService.WebAppAuth result/error contracts in the SDK,
# migrate its callers, then inject its methods directly and remove these adapters.
# Define SDK errors for timeouts, transport failures, upstream status and invalid
# responses before adding finer HTTP mappings; these adapters report unavailability.
# Validate required fields and real booleans there, replacing legacy permission
# truthiness conversion. Missing fields currently become False, {} or a default mode.
# Replace response-shape ValueError/KeyError/AttributeError with typed SDK errors;
# ordinary ValueError can still reach the global 400 invalid_param handler. The
# lost field information cannot be recovered by translating exceptions here.
def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode:
try:
settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
@@ -266,13 +279,39 @@ def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode:
raise WebAppAccessUnavailableError from e
def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
def _is_enterprise_webapp_user_allowed(user_id: str, app_id: str) -> bool:
try:
return EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id)
except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError) as e:
raise WebAppAccessUnavailableError from e
def _batch_get_enterprise_webapp_access_modes(*, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]:
try:
settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(list(app_ids))
except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError, ValidationError) as e:
raise WebAppAccessUnavailableError from e
access_modes: dict[str, WebAppAccessMode] = {}
for app_id, setting in settings.items():
try:
access_mode = WebAppAccessMode(setting.access_mode)
except ValueError:
logger.warning("Skipping invalid web app access mode %r for app %s", setting.access_mode, app_id)
continue
access_modes[app_id] = access_mode
return access_modes
def _batch_get_enterprise_webapp_user_permissions(*, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]:
try:
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
user_id=user_id, app_ids=list(app_ids)
)
except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError) as e:
raise WebAppAccessUnavailableError from e
return {app_id: bool(allowed) for app_id, allowed in permissions.items()}
@dataclass(frozen=True, slots=True)
class AccountServices:
access: AccountAccessService
@@ -335,6 +374,7 @@ class ApplicationServices:
init_validation: InitValidationService
installed_app_access: InstalledAppAccessService
installed_app_generation: InstalledAppGenerationService
installed_apps: InstalledAppService
notifications: NotificationService
step_by_step_tour: StepByStepTourService
partner_tenant_bindings: PartnerTenantBindingService
@@ -520,11 +560,20 @@ def build_application_services(
dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
),
)
webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition)
webapp_access = WebAppAccessQueryService(
access=WebAppAccessQueryRepository(session_factory=database_client),
webapp_auth_enabled=SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition),
webapp_auth_enabled=webapp_auth_enabled,
access_mode_for_app=_get_enterprise_webapp_access_mode,
is_user_allowed_for_app=_is_user_allowed_to_access_webapp,
is_user_allowed_for_app=_is_enterprise_webapp_user_allowed,
get_access_modes=_batch_get_enterprise_webapp_access_modes,
get_user_permissions=_batch_get_enterprise_webapp_user_permissions,
)
installed_app_access = InstalledAppAccessService(
installed_apps=installed_apps,
is_user_allowed=webapp_access.is_user_allowed,
get_access_modes=webapp_access.batch_get_access_modes,
get_user_permissions=webapp_access.batch_get_user_permissions,
)
app_preview_repository = AppPreviewQueryRepository(session_factory=database_client)
feature_gateway = FeatureServiceGateway()
@@ -755,15 +804,17 @@ def build_application_services(
queries=AppScopedEndUserQueryService(end_users=app_scoped_end_user_repository),
),
webapp_access=webapp_access,
installed_app_access=InstalledAppAccessService(
installed_apps=installed_apps,
is_user_allowed=webapp_access.is_user_allowed,
),
installed_app_access=installed_app_access,
installed_app_generation=InstalledAppGenerationService(
app_definitions=app_definitions,
usage=installed_apps,
runtime=InstalledAppGenerateServiceRuntime(session_factory=database_client),
),
installed_apps=InstalledAppService(
installed_apps=installed_apps,
get_workspace_role=workspace_query_repository.get_account_role,
get_visible_app_ids=installed_app_access.get_visible_app_ids if webapp_auth_enabled else None,
),
web_app_runtime=WebAppRuntimeQueryService(
runtime=app_definition_repository,
file_service=file_service,
-39
View File
@@ -6770,32 +6770,6 @@ Request body:
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [InstalledAppListResponse](#installedapplistresponse)<br> |
### [POST] /installed-apps
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [InstalledAppCreatePayload](#installedappcreatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleMessageResponse](#simplemessageresponse)<br> |
### [DELETE] /installed-apps/{installed_app_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| installed_app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description |
| ---- | ----------- |
| 204 | App uninstalled successfully |
### [GET] /installed-apps/{installed_app_id}
#### Parameters
@@ -19179,12 +19153,6 @@ Input field definition for snippet parameters.
| required | boolean | | No |
| type | string | | No |
#### InstalledAppCreatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
#### InstalledAppInfoResponse
| Name | Type | Description | Required |
@@ -19217,7 +19185,6 @@ Input field definition for snippet parameters.
| id | string | | Yes |
| is_pinned | boolean | | Yes |
| last_used_at | integer | | Yes |
| uninstallable | boolean | | Yes |
#### InstalledAppUpdatePayload
@@ -22235,12 +22202,6 @@ Resource types understood by access policies.
| message | string | | Yes |
| query | string | | Yes |
#### SimpleMessageResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| message | string | | Yes |
#### SimpleModelConfig
| Name | Type | Description | Required |
+135 -3
View File
@@ -1,18 +1,93 @@
"""Persist workspace installations with independently scoped operations."""
from datetime import datetime
from itertools import starmap
from typing import cast, override
from sqlalchemy import select, update
from sqlalchemy import ColumnElement, UnaryExpression, and_, exists, or_, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session, sessionmaker
from models.model import App, InstalledApp
from libs.helper import escape_like_pattern
from models.model import App, AppMode, AppModelConfig, InstalledApp
from models.workflow import Workflow
from services.installed_app_access_service import InstalledAppAccessStore, InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_generation_service import InstalledAppUsageRecorder
from services.installed_app_service import (
InstalledAppCursor,
InstalledAppInfo,
InstalledAppRecord,
InstalledAppStore,
)
class SQLAlchemyInstalledAppRepository(InstalledAppAccessStore, InstalledAppUsageRecorder):
def _published_app_filter() -> ColumnElement[bool]:
"""Match the model configuration used by installed-app parameters.
Keep the existing publication reference policy: workflow version, public
visibility and site settings do not decide whether an installation is listed.
"""
workflow_app_modes = (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW)
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return and_(
App.mode != AppMode.AGENT,
or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
),
)
def _installed_app_cursor_filter(cursor: InstalledAppCursor) -> ColumnElement[bool]:
same_pin_group = InstalledApp.is_pinned == cursor.is_pinned
if cursor.last_used_at is None:
later_in_pin_group = and_(
InstalledApp.last_used_at.is_(None),
InstalledApp.id > cursor.installed_app_id,
)
else:
later_in_pin_group = or_(
InstalledApp.last_used_at < cursor.last_used_at,
InstalledApp.last_used_at.is_(None),
and_(
InstalledApp.last_used_at == cursor.last_used_at,
InstalledApp.id > cursor.installed_app_id,
),
)
if cursor.is_pinned:
return or_(InstalledApp.is_pinned.is_(False), and_(same_pin_group, later_in_pin_group))
return and_(same_pin_group, later_in_pin_group)
def _installed_app_order_by() -> tuple[UnaryExpression[bool], UnaryExpression[datetime | None], UnaryExpression[str]]:
return (
InstalledApp.is_pinned.desc(),
InstalledApp.last_used_at.desc().nulls_last(),
InstalledApp.id.asc(),
)
def _to_record(installed_app: InstalledApp, app: App) -> InstalledAppRecord:
return InstalledAppRecord(
id=installed_app.id,
app=InstalledAppInfo(
id=app.id,
name=app.name,
description=app.description,
mode=app.mode,
icon_type=app.icon_type,
icon=app.icon,
icon_background=app.icon_background,
use_icon_as_answer_icon=app.use_icon_as_answer_icon,
),
app_owner_tenant_id=installed_app.app_owner_tenant_id,
is_pinned=installed_app.is_pinned,
last_used_at=installed_app.last_used_at,
)
class SQLAlchemyInstalledAppRepository(InstalledAppAccessStore, InstalledAppUsageRecorder, InstalledAppStore):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@@ -52,3 +127,60 @@ class SQLAlchemyInstalledAppRepository(InstalledAppAccessStore, InstalledAppUsag
)
if cast(CursorResult, result).rowcount == 0:
raise InstalledAppNotFoundError(f"Installed app {installed_app.id} no longer exists")
@override
def get_candidates(
self,
*,
tenant_id: str,
cursor: InstalledAppCursor | None,
limit: int,
app_id: str | None,
name: str | None,
) -> tuple[InstalledAppRecord, ...]:
statement = (
select(InstalledApp, App)
.join(App, App.id == InstalledApp.app_id)
.where(InstalledApp.tenant_id == tenant_id, _published_app_filter())
)
if app_id:
statement = statement.where(InstalledApp.app_id == app_id)
if name and (normalized_name := name.strip()):
escaped_name = escape_like_pattern(normalized_name)
statement = statement.where(App.name.ilike(f"%{escaped_name}%", escape="\\"))
if cursor is not None:
statement = statement.where(_installed_app_cursor_filter(cursor))
with self._session_factory() as session:
rows = session.execute(statement.order_by(*_installed_app_order_by()).limit(limit))
return tuple(starmap(_to_record, rows))
@override
def get_published(self, *, installed_app: InstalledAppRef) -> InstalledAppRecord | None:
with self._session_factory() as session:
row = session.execute(
select(InstalledApp, App)
.join(App, App.id == InstalledApp.app_id)
.where(
InstalledApp.id == installed_app.id,
InstalledApp.tenant_id == installed_app.tenant_id,
InstalledApp.app_id == installed_app.app_id,
_published_app_filter(),
)
.limit(1)
).first()
return None if row is None else _to_record(row[0], row[1])
@override
def set_pinned(self, *, installed_app: InstalledAppRef, is_pinned: bool) -> None:
with self._session_factory.begin() as session:
result = session.execute(
update(InstalledApp)
.where(
InstalledApp.id == installed_app.id,
InstalledApp.tenant_id == installed_app.tenant_id,
InstalledApp.app_id == installed_app.app_id,
)
.values(is_pinned=is_pinned)
)
if cast(CursorResult, result).rowcount == 0:
raise InstalledAppNotFoundError(f"Installed app {installed_app.id} no longer exists")
@@ -24,6 +24,17 @@ class WorkspaceQueryRepository(
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def get_account_role(self, *, account_id: str, tenant_id: str) -> str | None:
"""Read the current membership role without loading or mutating an Account."""
stmt = (
select(TenantAccountJoin.role)
.where(TenantAccountJoin.account_id == account_id, TenantAccountJoin.tenant_id == tenant_id)
.limit(1)
)
with self._session_factory() as session:
role = session.scalar(stmt)
return role.value if role is not None else None
@override
def list_for_account(self, account_id: str) -> tuple[WorkspaceRecord, ...]:
stmt = (
+32 -3
View File
@@ -1,8 +1,12 @@
"""Application admission for an app installed in a workspace."""
"""Resolve installed-app admission and account-specific list visibility."""
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from enums import WebAppAccessMode
from services.webapp_access_query_service import WebAppAccessModesQuery, WebAppUserPermissionsQuery
@dataclass(frozen=True, slots=True)
class InstalledAppRef:
@@ -33,9 +37,13 @@ class InstalledAppAccessService:
*,
installed_apps: InstalledAppAccessStore,
is_user_allowed: WebAppUserAccessCheck,
get_access_modes: WebAppAccessModesQuery,
get_user_permissions: WebAppUserPermissionsQuery,
) -> None:
self._installed_apps = installed_apps
self._is_user_allowed = is_user_allowed
self._installed_apps: InstalledAppAccessStore = installed_apps
self._is_user_allowed: WebAppUserAccessCheck = is_user_allowed
self._get_access_modes: WebAppAccessModesQuery = get_access_modes
self._get_user_permissions: WebAppUserPermissionsQuery = get_user_permissions
def get_access(self, *, installed_app_id: str, tenant_id: str, account_id: str) -> InstalledAppRef:
installed_app = self._installed_apps.resolve(installed_app_id=installed_app_id, tenant_id=tenant_id)
@@ -44,3 +52,24 @@ class InstalledAppAccessService:
if not self._is_user_allowed(user_id=account_id, app_id=installed_app.app_id):
raise InstalledAppAccessDeniedError("Access to installed app denied")
return installed_app
def get_visible_app_ids(self, *, user_id: str, app_ids: Sequence[str]) -> frozenset[str]:
"""Apply list visibility rules without changing single-app admission.
Missing access settings and SSO-only apps are omitted from the list.
Preserve candidate order for the subsequent batch permission query.
"""
if not app_ids:
return frozenset(app_ids)
access_modes = self._get_access_modes(app_ids=app_ids)
candidates = [
app_id
for app_id in app_ids
if app_id in access_modes and access_modes[app_id] != WebAppAccessMode.SSO_VERIFIED
]
if not candidates:
return frozenset()
permissions = self._get_user_permissions(user_id=user_id, app_ids=candidates)
return frozenset(app_id for app_id in candidates if permissions.get(app_id))
+113 -122
View File
@@ -1,14 +1,13 @@
"""Manage workspace installations and paginate apps visible to an account."""
from collections.abc import Sequence, Set
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from pydantic import BaseModel
from sqlalchemy import and_, exists, or_, select
from sqlalchemy.orm import Session, scoped_session
from libs.helper import escape_like_pattern
from models import App, AppModelConfig, InstalledApp, Workflow
from models.model import AppMode
from services.enterprise.enterprise_service import EnterpriseService
from services.system_feature_service import SystemFeatureService
from services.installed_app_access_service import InstalledAppRef
class InstalledAppCursor(BaseModel):
@@ -17,86 +16,90 @@ class InstalledAppCursor(BaseModel):
installed_app_id: str
def _published_app_filter():
"""Return the SQL predicate for installed-app web API availability.
The installed-app parameters endpoint reads the published workflow for
workflow-style apps and the published app model config for easy UI apps.
Keep the list endpoint aligned in SQL so it does not return entries that
will immediately fail with app_unavailable when opened.
"""
workflow_app_modes = (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW)
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return and_(
App.mode != AppMode.AGENT,
or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
),
)
@dataclass(frozen=True, slots=True)
class InstalledAppInfo:
id: str
name: str
description: str
mode: str
icon_type: str | None
icon: str | None
icon_background: str | None
use_icon_as_answer_icon: bool
def _installed_app_cursor_filter(cursor: InstalledAppCursor):
same_pin_group = InstalledApp.is_pinned == cursor.is_pinned
if cursor.last_used_at is None:
later_in_pin_group = and_(
InstalledApp.last_used_at.is_(None),
InstalledApp.id > cursor.installed_app_id,
)
else:
later_in_pin_group = or_(
InstalledApp.last_used_at < cursor.last_used_at,
InstalledApp.last_used_at.is_(None),
and_(
InstalledApp.last_used_at == cursor.last_used_at,
InstalledApp.id > cursor.installed_app_id,
),
@dataclass(frozen=True, slots=True)
class InstalledAppRecord:
id: str
app: InstalledAppInfo
app_owner_tenant_id: str
is_pinned: bool
last_used_at: datetime | None
def cursor(self) -> InstalledAppCursor:
return InstalledAppCursor(
is_pinned=self.is_pinned,
last_used_at=self.last_used_at,
installed_app_id=self.id,
)
if cursor.is_pinned:
return or_(
InstalledApp.is_pinned.is_(False),
and_(same_pin_group, later_in_pin_group),
)
return and_(same_pin_group, later_in_pin_group)
@dataclass(frozen=True, slots=True)
class InstalledAppPage:
data: tuple[InstalledAppRecord, ...]
has_more: bool
next_cursor: InstalledAppCursor | None
editable: bool
def _installed_app_order_by():
return (
InstalledApp.is_pinned.desc(),
InstalledApp.last_used_at.desc().nulls_last(),
InstalledApp.id.asc(),
)
@dataclass(frozen=True, slots=True)
class InstalledAppDetail:
installation: InstalledAppRecord
editable: bool
def _filter_rows_by_webapp_auth(
rows: list[tuple[InstalledApp, App]],
*,
user_id: str,
) -> list[tuple[InstalledApp, App]]:
if not rows:
return []
class InstalledAppUnavailableError(RuntimeError):
"""The admitted installation cannot be returned as a published library app."""
app_ids = [app.id for _, app in rows]
webapp_settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids)
candidates = [
(installed_app, app)
for installed_app, app in rows
if (setting := webapp_settings.get(app.id)) is not None and setting.access_mode != "sso_verified"
]
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
user_id=user_id,
app_ids=[app.id for _, app in candidates],
)
return [(installed_app, app) for installed_app, app in candidates if permissions.get(app.id)]
class InstalledAppStore(Protocol):
def get_candidates(
self,
*,
tenant_id: str,
cursor: InstalledAppCursor | None,
limit: int,
app_id: str | None,
name: str | None,
) -> tuple[InstalledAppRecord, ...]: ...
def get_published(self, *, installed_app: InstalledAppRef) -> InstalledAppRecord | None: ...
def set_pinned(self, *, installed_app: InstalledAppRef, is_pinned: bool) -> None: ...
class InstalledAppVisibilityQuery(Protocol):
def __call__(self, *, user_id: str, app_ids: Sequence[str]) -> Set[str]: ...
class WorkspaceRoleLookup(Protocol):
def __call__(self, *, account_id: str, tenant_id: str) -> str | None: ...
class InstalledAppService:
@classmethod
def __init__(
self,
*,
installed_apps: InstalledAppStore,
get_workspace_role: WorkspaceRoleLookup,
get_visible_app_ids: InstalledAppVisibilityQuery | None,
) -> None:
self._installed_apps: InstalledAppStore = installed_apps
self._get_workspace_role: WorkspaceRoleLookup = get_workspace_role
self._get_visible_app_ids: InstalledAppVisibilityQuery | None = get_visible_app_ids
def get_visible_page(
cls,
self,
*,
tenant_id: str,
user_id: str,
@@ -104,74 +107,62 @@ class InstalledAppService:
limit: int,
app_id: str | None,
name: str | None,
session: Session | scoped_session,
) -> tuple[list[tuple[InstalledApp, App]], bool, InstalledAppCursor | None]:
) -> InstalledAppPage:
"""Scan ordered candidates until one page of authorized apps is complete."""
stmt = (
select(InstalledApp, App)
.join(App, App.id == InstalledApp.app_id)
.where(InstalledApp.tenant_id == tenant_id, _published_app_filter())
)
if app_id:
stmt = stmt.where(InstalledApp.app_id == app_id)
if name and (normalized_name := name.strip()):
escaped_name = escape_like_pattern(normalized_name)
stmt = stmt.where(App.name.ilike(f"%{escaped_name}%", escape="\\"))
webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled()
scan_size = limit * 2 if webapp_auth_enabled else limit + 1
visible_rows: list[tuple[InstalledApp, App]] = []
scan_size = limit * 2 if self._get_visible_app_ids is not None else limit + 1
visible_rows: list[InstalledAppRecord] = []
scan_cursor = cursor
has_more = False
last_consumed_app: InstalledApp | None = None
last_consumed_app: InstalledAppRecord | None = None
while True:
page_stmt = stmt
if scan_cursor is not None:
page_stmt = page_stmt.where(_installed_app_cursor_filter(scan_cursor))
candidate_result = session.execute(page_stmt.order_by(*_installed_app_order_by()).limit(scan_size)).all()
candidate_rows = [(installed_app, app) for installed_app, app in candidate_result]
# Candidate records are detached and the repository Session is closed
# before the optional Enterprise request.
candidate_rows = self._installed_apps.get_candidates(
tenant_id=tenant_id,
cursor=scan_cursor,
limit=scan_size,
app_id=app_id,
name=name,
)
if not candidate_rows:
break
authorized_rows = candidate_rows
if webapp_auth_enabled:
authorized_rows = _filter_rows_by_webapp_auth(candidate_rows, user_id=user_id)
authorized_app_ids: Set[str] = {row.app.id for row in candidate_rows}
if self._get_visible_app_ids is not None:
authorized_app_ids = self._get_visible_app_ids(
user_id=user_id, app_ids=[row.app.id for row in candidate_rows]
)
authorized_installed_app_ids = {installed_app.id for installed_app, _ in authorized_rows}
for row in candidate_rows:
installed_app = row[0]
if installed_app.id not in authorized_installed_app_ids:
last_consumed_app = installed_app
if row.app.id not in authorized_app_ids:
last_consumed_app = row
continue
if len(visible_rows) == limit:
has_more = True
break
visible_rows.append(row)
last_consumed_app = installed_app
last_consumed_app = row
if has_more:
break
if len(candidate_rows) < scan_size:
break
last_scanned_app = candidate_rows[-1][0]
scan_cursor = InstalledAppCursor(
is_pinned=last_scanned_app.is_pinned,
last_used_at=last_scanned_app.last_used_at,
installed_app_id=last_scanned_app.id,
)
scan_cursor = candidate_rows[-1].cursor()
next_cursor = (
InstalledAppCursor(
is_pinned=last_consumed_app.is_pinned,
last_used_at=last_consumed_app.last_used_at,
installed_app_id=last_consumed_app.id,
)
if has_more and last_consumed_app
else None
next_cursor = last_consumed_app.cursor() if has_more and last_consumed_app is not None else None
role = self._get_workspace_role(account_id=user_id, tenant_id=tenant_id)
return InstalledAppPage(
data=tuple(visible_rows), has_more=has_more, next_cursor=next_cursor, editable=role in {"owner", "admin"}
)
return visible_rows, has_more, next_cursor
@staticmethod
def get_published_app(app_id: str, *, session: Session | scoped_session) -> App | None:
return session.scalar(select(App).where(App.id == app_id, _published_app_filter()).limit(1))
def get_detail(self, *, installed_app: InstalledAppRef, account_id: str) -> InstalledAppDetail:
installation = self._installed_apps.get_published(installed_app=installed_app)
if installation is None:
raise InstalledAppUnavailableError(f"Installed app {installed_app.id} is not available as a published app")
role = self._get_workspace_role(account_id=account_id, tenant_id=installed_app.tenant_id)
return InstalledAppDetail(installation=installation, editable=role in {"owner", "admin"})
def set_pinned(self, *, installed_app: InstalledAppRef, is_pinned: bool | None) -> None:
if is_pinned is not None:
self._installed_apps.set_pinned(installed_app=installed_app, is_pinned=is_pinned)
+30 -5
View File
@@ -1,6 +1,6 @@
"""Application service for resolving web-app access."""
from collections.abc import Callable
from collections.abc import Callable, Mapping, Sequence
from typing import Protocol
from enums import WebAppAccessMode
@@ -12,6 +12,14 @@ class WebAppAccessQuery(Protocol):
def find_app_id_by_code(self, app_code: str) -> str | None: ...
class WebAppAccessModesQuery(Protocol):
def __call__(self, *, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]: ...
class WebAppUserPermissionsQuery(Protocol):
def __call__(self, *, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]: ...
class WebAppAccessReferenceRequiredError(ValueError):
"""Raised when neither an app ID nor an app code was provided."""
@@ -32,11 +40,15 @@ class WebAppAccessQueryService:
webapp_auth_enabled: bool,
access_mode_for_app: Callable[[str], WebAppAccessMode],
is_user_allowed_for_app: Callable[[str, str], bool],
get_access_modes: WebAppAccessModesQuery,
get_user_permissions: WebAppUserPermissionsQuery,
) -> None:
self._access = access
self._webapp_auth_enabled = webapp_auth_enabled
self._access_mode_for_app = access_mode_for_app
self._is_user_allowed_for_app = is_user_allowed_for_app
self._access: WebAppAccessQuery = access
self._webapp_auth_enabled: bool = webapp_auth_enabled
self._access_mode_for_app: Callable[[str], WebAppAccessMode] = access_mode_for_app
self._is_user_allowed_for_app: Callable[[str, str], bool] = is_user_allowed_for_app
self._get_access_modes: WebAppAccessModesQuery = get_access_modes
self._get_user_permissions: WebAppUserPermissionsQuery = get_user_permissions
def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode:
if not self._webapp_auth_enabled:
@@ -60,3 +72,16 @@ class WebAppAccessQueryService:
return True
return self._is_user_allowed_for_app(user_id, app_id)
def batch_get_access_modes(self, *, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]:
"""Return successfully parsed modes; missing IDs do not grant access."""
if not self._webapp_auth_enabled or not app_ids:
return dict.fromkeys(app_ids, WebAppAccessMode.PUBLIC)
return self._get_access_modes(app_ids=app_ids)
def batch_get_user_permissions(self, *, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]:
if not self._webapp_auth_enabled or not app_ids:
return dict.fromkeys(app_ids, True)
return self._get_user_permissions(user_id=user_id, app_ids=app_ids)
@@ -301,7 +301,14 @@ def test_chat_enforces_admission_before_payload_validation(
)
else:
_assert_json_response(
response, status=404, body={"code": "not_found", "message": "Installed app not found", "status": 404}
response,
status=404,
body={
"code": "installed_app_not_found",
"message": "The app was not found in this workspace.",
"status": 404,
"details": {"request_id": "request-1"},
},
)
assert chat_runtime.calls == []
assert _last_used_at(harness, sqlite_session_factory) is None
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import json
from collections.abc import Callable, Generator
from collections.abc import Callable, Generator, Mapping, Sequence
from dataclasses import dataclass, field
from operator import itemgetter
from uuid import UUID, uuid4
@@ -14,6 +14,7 @@ from werkzeug.exceptions import Unauthorized
from werkzeug.test import TestResponse
import controllers.console.explore.completion as completion_module
import controllers.console.explore.error as explore_error_module
import controllers.console.explore.installed_app_admission as admission_module
import controllers.console.explore.parameter as parameter_module
import controllers.console.explore.saved_message as saved_message_module
@@ -25,7 +26,7 @@ import libs.login as login_module
import services.app_task_service as app_task_module
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from enums import DeploymentEdition
from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_login import DifyLoginManager, unauthorized_handler
from libs.external_api import ExternalApi
from machinery.context import RequestContext
@@ -124,10 +125,18 @@ def harness(
state.permission_action()
return state.allowed
def unexpected_access_modes(*, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]:
pytest.fail(f"Single-app admission should not query batch access modes: {app_ids=}")
def unexpected_user_permissions(*, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]:
pytest.fail(f"Single-app admission should not query batch permissions: {user_id=}, {app_ids=}")
services = _ApplicationServices(
installed_app_access=InstalledAppAccessService(
installed_apps=SQLAlchemyInstalledAppRepository(session_factory=repository_factory),
is_user_allowed=permission_check,
get_access_modes=unexpected_access_modes,
get_user_permissions=unexpected_user_permissions,
)
)
@@ -144,6 +153,7 @@ def harness(
monkeypatch.setattr(login_module, "check_csrf_token", check_csrf)
monkeypatch.setattr(console_wraps, "_is_setup_completed", setup_completed)
monkeypatch.setattr(console_admission, "get_request_id", lambda: "request-1")
monkeypatch.setattr(explore_error_module, "get_request_id", lambda: "request-1")
monkeypatch.setattr(console_admission, "get_trace_id", lambda: None)
app = Flask(__name__)
@@ -237,7 +247,12 @@ def test_missing_or_foreign_installation_returns_404_before_permission(
_assert_json_response(
response,
status=404,
body={"code": "not_found", "message": "Installed app not found", "status": 404},
body={
"code": "installed_app_not_found",
"message": "The app was not found in this workspace.",
"status": 404,
"details": {"request_id": "request-1"},
},
)
assert harness.state.permission_calls == []
assert harness.state.events == ["setup", "csrf"]
@@ -310,20 +325,42 @@ def test_account_admission_precedes_installed_app_lookup(
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
def test_permission_dependency_unavailable_returns_503_without_calling_handler(harness: _Harness) -> None:
harness.state.permission_error = WebAppAccessUnavailableError()
def test_permission_dependency_failure_preserves_cause_without_leaking_details(
harness: _Harness,
caplog: pytest.LogCaptureFixture,
) -> None:
failure = WebAppAccessUnavailableError("private upstream response containing credentials")
harness.state.permission_error = failure
exceptions: list[Exception] = []
response = harness.app.test_client().get(harness.url())
def capture_exception(_sender: Flask, exception: Exception) -> None:
exceptions.append(exception)
with got_request_exception.connected_to(capture_exception):
response = harness.app.test_client().get(harness.url())
_assert_json_response(
response,
status=503,
body={
"code": "web_app_access_unavailable",
"message": "Web app access service is unavailable.",
"message": "The app access service is unavailable. Try again later.",
"status": 503,
"details": {"request_id": "request-1"},
},
)
assert "private upstream" not in response.get_data(as_text=True)
assert len(exceptions) == 1
assert exceptions[0].__cause__ is failure
records = [record for record in caplog.records if record.name == admission_module.__name__]
assert len(records) == 1
assert records[0].exc_info is not None
assert records[0].exc_info[1] is failure
assert harness.installed_app.id in records[0].getMessage()
assert harness.installed_app.tenant_id in records[0].getMessage()
assert harness.account.id in records[0].getMessage()
assert "request-1" in records[0].getMessage()
assert "WWW-Authenticate" not in response.headers
assert harness.state.events == ["setup", "csrf", "permission"]
assert harness.state.permission_calls == [(harness.account.id, harness.target_app.id)]
@@ -348,7 +348,14 @@ def test_completion_requires_installed_app_admission_before_payload_validation(
)
else:
_assert_json_response(
response, status=404, body={"code": "not_found", "message": "Installed app not found", "status": 404}
response,
status=404,
body={
"code": "installed_app_not_found",
"message": "The app was not found in this workspace.",
"status": 404,
"details": {"request_id": "request-1"},
},
)
assert runtime.calls == []
if rejection != "missing":
@@ -388,6 +395,8 @@ def test_resource_removed_after_admission_does_not_start_runtime(
assert _last_used_at(harness, sqlite_session_factory) is None
else:
_assert_json_response(
response, status=404, body={"code": "not_found", "message": "Installed app not found", "status": 404}
response,
status=404,
body={"code": "not_found", "message": "Installed app not found", "status": 404},
)
assert runtime.calls == []
@@ -342,7 +342,14 @@ def test_workflow_handlers_require_admission_before_payload_or_task_actions(
)
else:
_assert_json_response(
response, status=404, body={"code": "not_found", "message": "Installed app not found", "status": 404}
response,
status=404,
body={
"code": "installed_app_not_found",
"message": "The app was not found in this workspace.",
"status": 404,
"details": {"request_id": "request-1"},
},
)
assert runtime.calls == []
assert stop_redis.operations == []
@@ -1,6 +1,8 @@
"""Tests for application-service dependency wiring."""
import json
import logging
from collections.abc import Mapping
from io import BytesIO
from types import SimpleNamespace
from typing import cast
@@ -10,8 +12,7 @@ from uuid import uuid4
import httpx
import pytest
from flask import Flask
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy import event, select
from sqlalchemy.orm import Session, sessionmaker
from core.tools.tool_file_manager import ToolFileManager
@@ -19,7 +20,7 @@ from enums import DeploymentEdition, WebAppAccessMode
from extensions import ext_application_services
from extensions.ext_redis import RedisClientWrapper
from machinery.context import RequestContext
from models.account import Account
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import CustomizeTokenStrategy
from models.model import AccountTrialAppRecord, App, AppMode, AppModelConfig, DifySetup, InstalledApp, Site, TrialApp
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
@@ -80,8 +81,7 @@ from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthS
from services.billing_portal_service import BillingPortalService
from services.billing_service import BillingService
from services.compliance_download_service import ComplianceDownloadService
from services.enterprise.enterprise_service import WebAppSettings
from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError
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
@@ -99,7 +99,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi
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.webapp_access_query_service import WebAppAccessUnavailableError
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
@@ -1064,87 +1064,87 @@ def test_build_application_services_adapts_enterprise_webapp_access_mode(
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(EnterpriseAPINotFoundError(), id="not-found"),
pytest.param(EnterpriseAPIError(), id="api-error"),
pytest.param(httpx.ConnectError("connection failed"), id="transport"),
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"),
pytest.param(
ValidationError.from_exception_data(WebAppSettings.__name__, []),
id="invalid-response",
),
],
)
def test_build_application_services_maps_known_enterprise_errors(
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("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",
side_effect=enterprise_error,
),
):
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.COMMUNITY,
deployment_edition=DeploymentEdition.ENTERPRISE,
initialization_password="",
redis=MagicMock(spec=RedisClientWrapper),
)
with pytest.raises(WebAppAccessUnavailableError) as raised:
services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
_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
def test_build_application_services_maps_invalid_access_mode_to_unavailable(
sqlite_session_factory: sessionmaker[Session],
@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("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="invalid"),
),
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.COMMUNITY,
deployment_edition=DeploymentEdition.ENTERPRISE,
initialization_password="",
redis=MagicMock(spec=RedisClientWrapper),
)
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)
def test_build_application_services_does_not_hide_unknown_enterprise_errors(
sqlite_session_factory: sessionmaker[Session],
@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:
failure = TypeError("adapter bug")
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",
side_effect=failure,
),
):
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.COMMUNITY,
deployment_edition=DeploymentEdition.ENTERPRISE,
initialization_password="",
redis=MagicMock(spec=RedisClientWrapper),
)
with pytest.raises(TypeError) as raised:
services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
with pytest.raises(type(failure)) as raised:
_query_webapp_access(services.webapp_access, query_kind)
assert raised.value is failure
@@ -1195,7 +1195,7 @@ def test_webapp_permission_adapter_maps_connection_failure() -> None:
),
pytest.raises(WebAppAccessUnavailableError) as raised,
):
ext_application_services._is_user_allowed_to_access_webapp("user-1", "app-1")
ext_application_services._is_enterprise_webapp_user_allowed("user-1", "app-1")
assert raised.value.__cause__ is failure
@@ -1233,3 +1233,271 @@ def test_build_application_services_wires_dynamic_recommended_catalog(
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=MagicMock(spec=RedisClientWrapper),
)
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_app_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.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.set_pinned(installed_app=installed_app_ref, is_pinned=True)
detail = services.installed_apps.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()
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=MagicMock(spec=RedisClientWrapper),
)
visible = services.installed_app_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=MagicMock(spec=RedisClientWrapper),
)
visible = services.installed_app_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=MagicMock(spec=RedisClientWrapper),
)
visible = services.installed_app_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=MagicMock(spec=RedisClientWrapper),
)
with pytest.raises(WebAppAccessUnavailableError) as caught:
services.installed_app_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)
@@ -1,14 +1,16 @@
from dataclasses import replace
from datetime import datetime
from datetime import datetime, timedelta
from uuid import uuid4
import pytest
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session, sessionmaker
from models.model import App, AppMode, InstalledApp
from models.model import App, AppMode, AppModelConfig, IconType, InstalledApp
from models.workflow import Workflow, WorkflowKind, WorkflowType
from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_service import InstalledAppInfo, InstalledAppRecord
_USED_AT = datetime(2026, 9, 6, 12, 30, 45)
_VIEWER_TENANT_ID = "11111111-1111-4111-8111-111111111111"
@@ -199,3 +201,257 @@ def test_record_rolls_back_and_preserves_commit_error(
stored = session.get(InstalledApp, installed_app.id)
assert stored is not None
assert stored.last_used_at is None
def _app_with_publication(
session: Session,
*,
name: str = "Shared app",
mode: AppMode = AppMode.COMPLETION,
published: bool = True,
) -> App:
app = App(
tenant_id=_OWNER_TENANT_ID,
name=name,
description="",
mode=mode,
icon_type=IconType.EMOJI,
icon="robot",
icon_background=None,
use_icon_as_answer_icon=False,
is_public=False,
enable_site=False,
enable_api=False,
)
session.add(app)
session.flush()
if published and mode in (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT):
# Preserve the existing reference-existence check, including draft versions.
workflow = Workflow(
id=str(uuid4()),
tenant_id=app.tenant_id,
app_id=app.id,
type=WorkflowType.WORKFLOW,
kind=WorkflowKind.STANDARD,
version="draft",
graph='{"nodes":[],"edges":[]}',
features="{}",
created_by=str(uuid4()),
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
session.add(workflow)
app.workflow_id = workflow.id
elif published:
configuration = AppModelConfig(app_id=app.id)
session.add(configuration)
session.flush()
app.app_model_config_id = configuration.id
return app
@pytest.mark.parametrize("mode", list(AppMode))
@pytest.mark.parametrize("publication", ["present", "missing", "dangling"])
def test_candidate_and_detail_publication_filters_preserve_mode_and_reference_rules(
sqlite_session_factory: sessionmaker[Session], mode: AppMode, publication: str
) -> None:
with sqlite_session_factory.begin() as session:
app = _app_with_publication(session, mode=mode, published=publication == "present")
if publication == "dangling":
if mode in (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW):
app.workflow_id = str(uuid4())
else:
app.app_model_config_id = str(uuid4())
installation = _installation(app_id=app.id)
session.add(installation)
session.flush()
reference = _reference(installation)
repository = SQLAlchemyInstalledAppRepository(session_factory=sqlite_session_factory)
candidates = repository.get_candidates(tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=10, app_id=None, name=None)
detail = repository.get_published(installed_app=reference)
if publication == "present" and mode != AppMode.AGENT:
assert len(candidates) == 1
assert candidates[0] == detail
assert candidates[0].app.mode == mode
else:
assert candidates == ()
assert detail is None
def test_candidates_return_detached_nested_data_and_stored_installation_owner(
sqlite_session_factory: sessionmaker[Session],
) -> None:
stored_owner_id = str(uuid4())
with sqlite_session_factory.begin() as session:
app = _app_with_publication(session)
installation = _installation(app_id=app.id)
installation.app_owner_tenant_id = stored_owner_id
installation.last_used_at = _USED_AT
session.add(installation)
session.flush()
installation_id, app_id = installation.id, app.id
repository = SQLAlchemyInstalledAppRepository(session_factory=sqlite_session_factory)
result = repository.get_candidates(tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=10, app_id=None, name=None)
assert result == (
InstalledAppRecord(
id=installation_id,
app=InstalledAppInfo(
id=app_id,
name="Shared app",
description="",
mode="completion",
icon_type="emoji",
icon="robot",
icon_background=None,
use_icon_as_answer_icon=False,
),
app_owner_tenant_id=stored_owner_id,
is_pinned=False,
last_used_at=_USED_AT,
),
)
assert inspect(result[0], raiseerr=False) is None
assert inspect(result[0].app, raiseerr=False) is None
def test_candidates_order_and_cursor_cover_pin_groups_ties_and_null_dates(
sqlite_session_factory: sessionmaker[Session],
) -> None:
ordered: list[str] = []
rows = [
(True, _USED_AT),
(True, _USED_AT),
(True, _USED_AT - timedelta(days=1)),
(True, None),
(True, None),
(False, _USED_AT + timedelta(days=1)),
(False, _USED_AT),
(False, _USED_AT),
(False, None),
(False, None),
]
with sqlite_session_factory.begin() as session:
for number, (is_pinned, last_used_at) in enumerate(rows, start=1):
app = _app_with_publication(session)
installation = _installation(app_id=app.id)
installation.id = f"00000000-0000-4000-8000-{number:012d}"
installation.is_pinned = is_pinned
installation.last_used_at = last_used_at
session.add(installation)
ordered.append(installation.id)
repository = SQLAlchemyInstalledAppRepository(session_factory=sqlite_session_factory)
all_rows = repository.get_candidates(tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=20, app_id=None, name=None)
assert [row.id for row in all_rows] == ordered
for index, row in enumerate(all_rows):
after = repository.get_candidates(
tenant_id=_VIEWER_TENANT_ID, cursor=row.cursor(), limit=20, app_id=None, name=None
)
assert [candidate.id for candidate in after] == ordered[index + 1 :]
assert repository.get_candidates(tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=0, app_id=None, name=None) == ()
assert (
repository.get_candidates(tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=2, app_id=None, name=None)
== all_rows[:2]
)
@pytest.mark.parametrize("search", [" 50%_\\ ", "50%_\\", "literal"])
def test_candidates_filter_literal_case_insensitive_name_and_app_with_workspace_scope(
sqlite_session_factory: sessionmaker[Session], search: str
) -> None:
with sqlite_session_factory.begin() as session:
matching = _app_with_publication(session, name="LITERAL 50%_\\ suffix")
another = _app_with_publication(session, name="Literal 5000 suffix")
foreign = _app_with_publication(session, name=matching.name)
session.add_all([_installation(app_id=matching.id), _installation(app_id=another.id)])
foreign_installation = _installation(app_id=foreign.id)
foreign_installation.tenant_id = _OWNER_TENANT_ID
session.add(foreign_installation)
repository = SQLAlchemyInstalledAppRepository(session_factory=sqlite_session_factory)
candidates = repository.get_candidates(
tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=10, app_id=matching.id, name=search
)
assert [row.app.id for row in candidates] == [matching.id]
if search != "literal":
by_name = repository.get_candidates(
tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=10, app_id=None, name=search
)
assert [row.app.id for row in by_name] == [matching.id]
assert len(repository.get_candidates(tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=10, app_id="", name=" ")) == 2
assert (
repository.get_candidates(tenant_id=_VIEWER_TENANT_ID, cursor=None, limit=10, app_id=foreign.id, name=None)
== ()
)
@pytest.mark.parametrize("field", ["id", "tenant_id", "app_id"])
@pytest.mark.parametrize("operation", ["get_published", "set_pinned"])
def test_management_operations_require_all_admitted_reference_fields(
sqlite_session_factory: sessionmaker[Session], field: str, operation: str
) -> None:
with sqlite_session_factory.begin() as session:
app = _app_with_publication(session)
installation = _installation(app_id=app.id)
session.add(installation)
session.flush()
repository = SQLAlchemyInstalledAppRepository(session_factory=sqlite_session_factory)
reference = replace(_reference(installation), **{field: str(uuid4())})
if operation == "get_published":
assert repository.get_published(installed_app=reference) is None
else:
with pytest.raises(InstalledAppNotFoundError, match=reference.id):
repository.set_pinned(installed_app=reference, is_pinned=True)
with sqlite_session_factory() as session:
stored = session.get(InstalledApp, installation.id)
assert stored is not None
assert stored.is_pinned is False
@pytest.mark.parametrize("is_pinned", [True, False])
def test_pin_updates_only_pin_state_including_unpublished_apps(
sqlite_session_factory: sessionmaker[Session], installed_app: InstalledApp, is_pinned: bool
) -> None:
repository = SQLAlchemyInstalledAppRepository(session_factory=sqlite_session_factory)
repository.set_pinned(installed_app=_reference(installed_app), is_pinned=is_pinned)
repository.set_pinned(installed_app=_reference(installed_app), is_pinned=is_pinned)
with sqlite_session_factory() as session:
stored = session.get(InstalledApp, installed_app.id)
assert stored is not None
assert stored.is_pinned is is_pinned
assert stored.last_used_at is None
assert stored.position == 7
assert stored.app_owner_tenant_id == installed_app.app_owner_tenant_id
def test_pin_rolls_back_and_preserves_database_failure(
sqlite_session_factory: sessionmaker[Session], installed_app: InstalledApp
) -> None:
failure = RuntimeError("installation commit unavailable")
repository_factory = sessionmaker(bind=sqlite_session_factory.kw["bind"], expire_on_commit=False)
@event.listens_for(repository_factory, "before_commit")
def fail_commit(session: Session) -> None:
session.flush()
raise failure
repository = SQLAlchemyInstalledAppRepository(session_factory=repository_factory)
with pytest.raises(RuntimeError, match="installation commit unavailable") as caught:
repository.set_pinned(installed_app=_reference(installed_app), is_pinned=False)
assert caught.value is failure
with sqlite_session_factory() as session:
stored = session.get(InstalledApp, installed_app.id)
assert stored is not None
assert stored.is_pinned is True
@@ -1,5 +1,9 @@
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
import pytest
from enums import WebAppAccessMode
from services.installed_app_access_service import (
InstalledAppAccessDeniedError,
InstalledAppAccessService,
@@ -29,6 +33,39 @@ class _Store:
return self._result
def _unexpected_access_modes(*, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]:
pytest.fail(f"Unexpected batch access-mode query: {app_ids=}")
def _unexpected_user_permissions(*, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]:
pytest.fail(f"Unexpected batch permission query: {user_id=}, {app_ids=}")
def _unexpected_single_permission(*, user_id: str, app_id: str) -> bool:
pytest.fail(f"Visibility must use batch permission checks: {user_id=}, {app_id=}")
@dataclass
class _BatchAccess:
modes: Mapping[str, WebAppAccessMode] = field(default_factory=dict)
permissions: Mapping[str, bool] = field(default_factory=dict)
events: list[tuple[str, ...]] = field(default_factory=list)
modes_error: Exception | None = None
permissions_error: Exception | None = None
def get_access_modes(self, *, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]:
self.events.append(("modes", *app_ids))
if self.modes_error is not None:
raise self.modes_error
return self.modes
def get_user_permissions(self, *, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]:
self.events.append(("permissions", user_id, *app_ids))
if self.permissions_error is not None:
raise self.permissions_error
return self.permissions
@pytest.mark.parametrize("allowed", [True, False])
def test_access_resolves_workspace_installation_before_checking_target_app(allowed: bool) -> None:
events: list[tuple[str, ...]] = []
@@ -40,6 +77,8 @@ def test_access_resolves_workspace_installation_before_checking_target_app(allow
service = InstalledAppAccessService(
installed_apps=_Store(result=_REF, events=events),
is_user_allowed=is_user_allowed,
get_access_modes=_unexpected_access_modes,
get_user_permissions=_unexpected_user_permissions,
)
if allowed:
@@ -60,6 +99,8 @@ def test_missing_installation_does_not_check_permission() -> None:
service = InstalledAppAccessService(
installed_apps=_Store(result=None, events=events),
is_user_allowed=unexpected_permission_check,
get_access_modes=_unexpected_access_modes,
get_user_permissions=_unexpected_user_permissions,
)
with pytest.raises(InstalledAppNotFoundError, match="^Installed app not found$"):
@@ -80,6 +121,8 @@ def test_dependency_errors_propagate_unchanged(failure_source: str) -> None:
service = InstalledAppAccessService(
installed_apps=_Store(result=_REF, events=events, error=failure if failure_source == "store" else None),
is_user_allowed=is_user_allowed,
get_access_modes=_unexpected_access_modes,
get_user_permissions=_unexpected_user_permissions,
)
with pytest.raises(RuntimeError) as raised:
@@ -87,3 +130,136 @@ def test_dependency_errors_propagate_unchanged(failure_source: str) -> None:
assert raised.value is failure
assert len(events) == (1 if failure_source == "store" else 2)
def test_visibility_skips_external_queries_when_empty() -> None:
events: list[tuple[str, ...]] = []
service = InstalledAppAccessService(
installed_apps=_Store(result=_REF, events=events, error=AssertionError("Visibility must not resolve an app")),
is_user_allowed=_unexpected_single_permission,
get_access_modes=_unexpected_access_modes,
get_user_permissions=_unexpected_user_permissions,
)
visible = service.get_visible_app_ids(user_id="account-1", app_ids=[])
assert visible == frozenset()
assert isinstance(visible, frozenset)
assert events == []
def test_visibility_filters_modes_then_permissions_preserving_batch_order_and_duplicates() -> None:
app_ids = ["public", "sso", "missing", "private", "private-all", "public", "also-public", "denied", "no-permission"]
batches = _BatchAccess(
modes={
"public": WebAppAccessMode.PUBLIC,
"sso": WebAppAccessMode.SSO_VERIFIED,
"private": WebAppAccessMode.PRIVATE,
"private-all": WebAppAccessMode.PRIVATE_ALL,
"also-public": WebAppAccessMode.PUBLIC,
"denied": WebAppAccessMode.PRIVATE,
"no-permission": WebAppAccessMode.PUBLIC,
},
permissions={
"public": True,
"private": True,
"private-all": True,
"also-public": True,
"denied": False,
"sso": True,
"missing": True,
"not-requested": True,
},
)
events: list[tuple[str, ...]] = []
service = InstalledAppAccessService(
installed_apps=_Store(result=_REF, events=events, error=AssertionError("Visibility must not resolve an app")),
is_user_allowed=_unexpected_single_permission,
get_access_modes=batches.get_access_modes,
get_user_permissions=batches.get_user_permissions,
)
visible = service.get_visible_app_ids(user_id="account-1", app_ids=app_ids)
assert visible == frozenset({"public", "private", "private-all", "also-public"})
assert isinstance(visible, frozenset)
assert batches.events == [
("modes", *app_ids),
(
"permissions",
"account-1",
"public",
"private",
"private-all",
"public",
"also-public",
"denied",
"no-permission",
),
]
assert events == []
@pytest.mark.parametrize("app_ids", [["missing"], ["sso", "missing", "sso"]])
def test_visibility_skips_permission_query_when_no_mode_is_eligible(app_ids: list[str]) -> None:
batches = _BatchAccess(
modes={"sso": WebAppAccessMode.SSO_VERIFIED},
permissions_error=AssertionError("Empty candidates must not reach the permission provider"),
)
service = InstalledAppAccessService(
installed_apps=_Store(result=_REF, events=[], error=AssertionError("Visibility must not resolve an app")),
is_user_allowed=_unexpected_single_permission,
get_access_modes=batches.get_access_modes,
get_user_permissions=batches.get_user_permissions,
)
assert service.get_visible_app_ids(user_id="account-1", app_ids=app_ids) == frozenset()
assert batches.events == [("modes", *app_ids)]
@pytest.mark.parametrize("failure_source", ["modes", "permissions"])
def test_visibility_preserves_dependency_exception_and_query_order(failure_source: str) -> None:
failure = RuntimeError("Enterprise request failed")
batches = _BatchAccess(modes={"app-1": WebAppAccessMode.PUBLIC})
if failure_source == "modes":
batches.modes_error = failure
else:
batches.permissions_error = failure
service = InstalledAppAccessService(
installed_apps=_Store(result=_REF, events=[], error=AssertionError("Visibility must not resolve an app")),
is_user_allowed=_unexpected_single_permission,
get_access_modes=batches.get_access_modes,
get_user_permissions=batches.get_user_permissions,
)
with pytest.raises(RuntimeError) as raised:
service.get_visible_app_ids(user_id="account-1", app_ids=["app-1"])
assert raised.value is failure
assert batches.events == (
[("modes", "app-1")]
if failure_source == "modes"
else [("modes", "app-1"), ("permissions", "account-1", "app-1")]
)
def test_single_app_admission_does_not_apply_list_sso_filter() -> None:
events: list[tuple[str, ...]] = []
batches = _BatchAccess(modes={_REF.app_id: WebAppAccessMode.SSO_VERIFIED}, permissions={_REF.app_id: False})
def allowed(*, user_id: str, app_id: str) -> bool:
events.append(("authorize", user_id, app_id))
return True
service = InstalledAppAccessService(
installed_apps=_Store(result=_REF, events=events),
is_user_allowed=allowed,
get_access_modes=batches.get_access_modes,
get_user_permissions=batches.get_user_permissions,
)
admitted = service.get_access(installed_app_id=_REF.id, tenant_id=_REF.tenant_id, account_id="account-1")
assert admitted is _REF
assert batches.events == []
assert events == [("resolve", _REF.id, _REF.tenant_id), ("authorize", "account-1", _REF.app_id)]
@@ -1,3 +1,5 @@
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from unittest.mock import MagicMock, create_autospec
import pytest
@@ -11,6 +13,14 @@ from services.webapp_access_query_service import (
)
def _unexpected_access_modes(*, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]:
raise AssertionError(f"Unexpected batch access mode query: {app_ids}")
def _unexpected_user_permissions(*, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]:
raise AssertionError(f"Unexpected batch permission query: {user_id}, {app_ids}")
def _service(
*,
access: MagicMock,
@@ -26,6 +36,8 @@ def _service(
webapp_auth_enabled=enabled,
access_mode_for_app=access_mode_for_app,
is_user_allowed_for_app=is_user_allowed_for_app,
get_access_modes=_unexpected_access_modes,
get_user_permissions=_unexpected_user_permissions,
),
access_mode_for_app,
is_user_allowed_for_app,
@@ -148,3 +160,111 @@ def test_enabled_auth_delegates_user_permission() -> None:
assert service.is_user_allowed(user_id="user-1", app_id="app-1") is False
is_user_allowed_for_app.assert_called_once_with("user-1", "app-1")
@dataclass
class _BatchQueries:
access_modes: Mapping[str, WebAppAccessMode] = field(default_factory=dict)
user_permissions: Mapping[str, bool] = field(default_factory=dict)
mode_requests: list[tuple[str, ...]] = field(default_factory=list)
permission_requests: list[tuple[str, tuple[str, ...]]] = field(default_factory=list)
mode_error: Exception | None = None
permission_error: Exception | None = None
def get_access_modes(self, *, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]:
self.mode_requests.append(tuple(app_ids))
if self.mode_error is not None:
raise self.mode_error
return self.access_modes
def get_user_permissions(self, *, user_id: str, app_ids: Sequence[str]) -> Mapping[str, bool]:
self.permission_requests.append((user_id, tuple(app_ids)))
if self.permission_error is not None:
raise self.permission_error
return self.user_permissions
def find_app_id_by_code(self, app_code: str) -> str | None:
raise AssertionError(f"Batch queries must not resolve app codes: {app_code}")
def get_access_mode(self, app_id: str) -> WebAppAccessMode:
raise AssertionError(f"Batch queries must not use single-app lookups: {app_id}")
def is_user_allowed(self, user_id: str, app_id: str) -> bool:
raise AssertionError(f"Batch queries must not use single-app permissions: {user_id}, {app_id}")
def _batch_service(queries: _BatchQueries, *, enabled: bool = True) -> WebAppAccessQueryService:
return WebAppAccessQueryService(
access=queries,
webapp_auth_enabled=enabled,
access_mode_for_app=queries.get_access_mode,
is_user_allowed_for_app=queries.is_user_allowed,
get_access_modes=queries.get_access_modes,
get_user_permissions=queries.get_user_permissions,
)
@pytest.mark.parametrize(
("enabled", "app_ids"),
[
pytest.param(False, ("app-b", "app-a", "app-b"), id="disabled-auth"),
pytest.param(False, (), id="disabled-empty"),
pytest.param(True, (), id="enabled-empty"),
],
)
def test_disabled_or_empty_batch_queries_return_public_access_without_external_calls(
enabled: bool, app_ids: tuple[str, ...]
) -> None:
queries = _BatchQueries(
mode_error=AssertionError("Must not query modes"),
permission_error=AssertionError("Must not query permissions"),
)
service = _batch_service(queries, enabled=enabled)
assert service.batch_get_access_modes(app_ids=app_ids) == dict.fromkeys(app_ids, WebAppAccessMode.PUBLIC)
assert service.batch_get_user_permissions(user_id="viewer", app_ids=app_ids) == dict.fromkeys(app_ids, True)
assert queries.mode_requests == []
assert queries.permission_requests == []
def test_enabled_batch_queries_preserve_order_duplicates_missing_results_and_sso_mode() -> None:
app_ids = ("private", "sso", "public", "private", "missing")
queries = _BatchQueries(
access_modes={
"private": WebAppAccessMode.PRIVATE,
"sso": WebAppAccessMode.SSO_VERIFIED,
"public": WebAppAccessMode.PUBLIC,
},
user_permissions={"private": False, "sso": True, "public": True},
)
service = _batch_service(queries)
modes = service.batch_get_access_modes(app_ids=app_ids)
permissions = service.batch_get_user_permissions(user_id="viewer", app_ids=app_ids)
assert modes == queries.access_modes
assert modes["sso"] is WebAppAccessMode.SSO_VERIFIED
assert "missing" not in modes
assert permissions == queries.user_permissions
assert "missing" not in permissions
assert queries.mode_requests == [app_ids]
assert queries.permission_requests == [("viewer", app_ids)]
@pytest.mark.parametrize("failure_stage", ["modes", "permissions"])
@pytest.mark.parametrize("failure", [ConnectionError("batch unavailable"), ValueError("invalid batch response")])
def test_batch_callback_errors_propagate_without_wrapping(failure_stage: str, failure: Exception) -> None:
queries = _BatchQueries(mode_error=failure, permission_error=failure)
service = _batch_service(queries)
if failure_stage == "modes":
with pytest.raises(type(failure)) as raised:
service.batch_get_access_modes(app_ids=("app-1",))
assert queries.mode_requests == [("app-1",)]
assert queries.permission_requests == []
else:
with pytest.raises(type(failure)) as raised:
service.batch_get_user_permissions(user_id="viewer", app_ids=("app-1",))
assert queries.mode_requests == []
assert queries.permission_requests == [("viewer", ("app-1",))]
assert raised.value is failure
@@ -5,8 +5,6 @@ import * as z from 'zod'
import {
zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath,
zDeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse,
zDeleteInstalledAppsByInstalledAppIdPath,
zDeleteInstalledAppsByInstalledAppIdResponse,
zDeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdPath,
zDeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponse,
zGetInstalledAppsByInstalledAppIdConversationsPath,
@@ -38,7 +36,6 @@ import {
zPatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponse,
zPatchInstalledAppsByInstalledAppIdPath,
zPatchInstalledAppsByInstalledAppIdResponse,
zPostInstalledAppsBody,
zPostInstalledAppsByInstalledAppIdAudioToTextPath,
zPostInstalledAppsByInstalledAppIdAudioToTextResponse,
zPostInstalledAppsByInstalledAppIdChatMessagesBody,
@@ -68,7 +65,6 @@ import {
zPostInstalledAppsByInstalledAppIdWorkflowsRunResponse,
zPostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopPath,
zPostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponse,
zPostInstalledAppsResponse,
} from './zod.gen.ts'
export const post = oc
@@ -510,18 +506,6 @@ export const workflows = {
tasks,
}
export const delete3 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteInstalledAppsByInstalledAppId',
path: '/installed-apps/{installed_app_id}',
successStatus: 204,
tags: ['console'],
})
.input(z.object({ params: zDeleteInstalledAppsByInstalledAppIdPath }))
.output(zDeleteInstalledAppsByInstalledAppIdResponse)
export const get8 = oc
.route({
inputStructure: 'detailed',
@@ -550,7 +534,6 @@ export const patch3 = oc
.output(zPatchInstalledAppsByInstalledAppIdResponse)
export const byInstalledAppId = {
delete: delete3,
get: get8,
patch: patch3,
audioToText,
@@ -576,20 +559,8 @@ export const get9 = oc
.input(z.object({ query: zGetInstalledAppsQuery.optional() }))
.output(zGetInstalledAppsResponse)
export const post12 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postInstalledApps',
path: '/installed-apps',
tags: ['console'],
})
.input(z.object({ body: zPostInstalledAppsBody }))
.output(zPostInstalledAppsResponse)
export const installedApps = {
get: get9,
post: post12,
byInstalledAppId,
}
@@ -10,14 +10,6 @@ export type InstalledAppListResponse = {
next_cursor: string | null
}
export type InstalledAppCreatePayload = {
app_id: string
}
export type SimpleMessageResponse = {
message: string
}
export type InstalledAppResponse = {
app: InstalledAppInfoResponse
app_owner_tenant_id: string
@@ -25,7 +17,6 @@ export type InstalledAppResponse = {
id: string
is_pinned: boolean
last_used_at: number | null
uninstallable: boolean
}
export type InstalledAppUpdatePayload = {
@@ -768,7 +759,6 @@ export type InstalledAppResponseWritable = {
id: string
is_pinned: boolean
last_used_at: number | null
uninstallable: boolean
}
export type ExploreMessageInfiniteScrollPaginationWritable = {
@@ -831,35 +821,6 @@ export type GetInstalledAppsResponses = {
export type GetInstalledAppsResponse = GetInstalledAppsResponses[keyof GetInstalledAppsResponses]
export type PostInstalledAppsData = {
body: InstalledAppCreatePayload
path?: never
query?: never
url: '/installed-apps'
}
export type PostInstalledAppsResponses = {
200: SimpleMessageResponse
}
export type PostInstalledAppsResponse = PostInstalledAppsResponses[keyof PostInstalledAppsResponses]
export type DeleteInstalledAppsByInstalledAppIdData = {
body?: never
path: {
installed_app_id: string
}
query?: never
url: '/installed-apps/{installed_app_id}'
}
export type DeleteInstalledAppsByInstalledAppIdResponses = {
204: void
}
export type DeleteInstalledAppsByInstalledAppIdResponse =
DeleteInstalledAppsByInstalledAppIdResponses[keyof DeleteInstalledAppsByInstalledAppIdResponses]
export type GetInstalledAppsByInstalledAppIdData = {
body?: never
path: {
@@ -2,20 +2,6 @@
import * as z from 'zod'
/**
* InstalledAppCreatePayload
*/
export const zInstalledAppCreatePayload = z.object({
app_id: z.string(),
})
/**
* SimpleMessageResponse
*/
export const zSimpleMessageResponse = z.object({
message: z.string(),
})
/**
* InstalledAppUpdatePayload
*/
@@ -619,7 +605,6 @@ export const zInstalledAppResponse = z.object({
id: z.string(),
is_pinned: z.boolean(),
last_used_at: z.int().nullable(),
uninstallable: z.boolean(),
})
/**
@@ -941,7 +926,6 @@ export const zInstalledAppResponseWritable = z.object({
id: z.string(),
is_pinned: z.boolean(),
last_used_at: z.int().nullable(),
uninstallable: z.boolean(),
})
/**
@@ -1003,22 +987,6 @@ export const zGetInstalledAppsQuery = z.object({
*/
export const zGetInstalledAppsResponse = zInstalledAppListResponse
export const zPostInstalledAppsBody = zInstalledAppCreatePayload
/**
* Success
*/
export const zPostInstalledAppsResponse = zSimpleMessageResponse
export const zDeleteInstalledAppsByInstalledAppIdPath = z.object({
installed_app_id: z.uuid(),
})
/**
* App uninstalled successfully
*/
export const zDeleteInstalledAppsByInstalledAppIdResponse = z.void()
export const zGetInstalledAppsByInstalledAppIdPath = z.object({
installed_app_id: z.uuid(),
})
@@ -451,7 +451,6 @@ describe('AppCard', () => {
editable: false,
is_pinned: false,
last_used_at: null,
uninstallable: true,
app: {
id: mockApp.id,
name: mockApp.name,
@@ -1234,7 +1234,6 @@ describe('useChatWithHistory', () => {
editable: true,
is_pinned: false,
last_used_at: null,
uninstallable: true,
app: {
id: 'app-id',
name: 'Installed App',
@@ -28,7 +28,6 @@ const baseProps = {
editable: true,
is_pinned: false,
last_used_at: null,
uninstallable: false,
app: {
id: 'source-app-123',
name: 'My App',
@@ -43,7 +42,6 @@ const baseProps = {
} satisfies InstalledAppResponse,
isSelected: false,
onTogglePin: vi.fn(),
onDelete: vi.fn(),
}
describe('AppNavItem', () => {
@@ -108,18 +106,6 @@ describe('AppNavItem', () => {
expect(link).toHaveAttribute('aria-current', 'page')
})
it('should call onDelete with app id when delete action is clicked', async () => {
const user = userEvent.setup()
render(<AppNavItem {...baseProps} />)
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
await user.click(await screen.findByText('explore.sidebar.action.delete'))
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
})
it('should request the next pin state', async () => {
const user = userEvent.setup()
render(<AppNavItem {...baseProps} />)
@@ -132,25 +118,4 @@ describe('AppNavItem', () => {
expect(baseProps.onTogglePin).toHaveBeenCalledWith('app-123', true)
})
})
describe('Edge Cases', () => {
it('should not render delete action when app is uninstallable', async () => {
const user = userEvent.setup()
render(
<AppNavItem
{...baseProps}
app={{
...baseProps.app,
uninstallable: true,
}}
/>,
)
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
})
})
})
@@ -10,20 +10,17 @@ type IAppNavItemProps = {
app: InstalledAppResponse
isSelected: boolean
onTogglePin: (id: string, isPinned: boolean) => void
onDelete: (id: string) => void
}
export default function AppNavItem({
app: installedApp,
isSelected,
onTogglePin,
onDelete,
}: IAppNavItemProps) {
const [isPrefetchEnabled, setIsPrefetchEnabled] = useState(false)
const {
id,
is_pinned: isPinned,
uninstallable,
app: { name, icon_type, icon, icon_background, icon_url },
} = installedApp
const url = buildInstalledAppPath(id)
@@ -59,8 +56,6 @@ export default function AppNavItem({
itemName={name}
isPinned={isPinned}
togglePin={() => onTogglePin(id, !isPinned)}
isShowDelete={!uninstallable && !isSelected}
onDelete={() => onDelete(id)}
/>
</div>
</div>
@@ -92,7 +92,6 @@ const createInstalledApp = (mode: AppMode = 'chat'): InstalledAppResponse => ({
editable: true,
is_pinned: false,
last_used_at: null,
uninstallable: true,
app: {
id: 'app-123',
name: 'Test App',
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import ItemOperation from '../index'
@@ -11,9 +12,7 @@ describe('ItemOperation', () => {
const props: React.ComponentProps<typeof ItemOperation> = {
itemName: 'My App',
isPinned: false,
isShowDelete: true,
togglePin: vi.fn(),
onDelete: vi.fn(),
...overrides,
}
return {
@@ -26,20 +25,8 @@ describe('ItemOperation', () => {
it('should distinguish operation triggers by item name', () => {
render(
<>
<ItemOperation
itemName="First App"
isPinned={false}
isShowDelete
togglePin={vi.fn()}
onDelete={vi.fn()}
/>
<ItemOperation
itemName="Second App"
isPinned={false}
isShowDelete
togglePin={vi.fn()}
onDelete={vi.fn()}
/>
<ItemOperation itemName="First App" isPinned={false} togglePin={vi.fn()} />
<ItemOperation itemName="Second App" isPinned={false} togglePin={vi.fn()} />
</>,
)
@@ -51,23 +38,24 @@ describe('ItemOperation', () => {
).toBeInTheDocument()
})
it('should render pin and delete actions when menu is open', async () => {
it('should render pin action when menu is open', async () => {
const user = userEvent.setup()
renderComponent()
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument()
expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should render rename action when isShowRenameConversation is true', async () => {
const user = userEvent.setup()
renderComponent({ isShowRenameConversation: true })
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
@@ -75,9 +63,10 @@ describe('ItemOperation', () => {
})
it('should render unpin label when isPinned is true', async () => {
const user = userEvent.setup()
renderComponent({ isPinned: true })
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
@@ -87,38 +76,29 @@ describe('ItemOperation', () => {
describe('User Interactions', () => {
it('should call togglePin when clicking pin action', async () => {
const user = userEvent.setup()
const { props } = renderComponent()
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
await user.click(await screen.findByText('explore.sidebar.action.pin'))
expect(props.togglePin).toHaveBeenCalledTimes(1)
})
it('should call onDelete when clicking delete action', async () => {
const { props } = renderComponent()
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
expect(props.onDelete).toHaveBeenCalledTimes(1)
})
it('should call onRenameConversation when clicking rename action', async () => {
const user = userEvent.setup()
const onRenameConversation = vi.fn()
renderComponent({
isShowRenameConversation: true,
onRenameConversation,
})
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.rename'))
await user.click(await screen.findByText('explore.sidebar.action.rename'))
expect(onRenameConversation).toHaveBeenCalledTimes(1)
})
@@ -126,8 +106,9 @@ describe('ItemOperation', () => {
describe('Edge Cases', () => {
it('should keep the menu open after rerender', async () => {
const user = userEvent.setup()
const { props, rerender } = renderComponent()
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
await screen.findByText('explore.sidebar.action.pin')
@@ -138,25 +119,20 @@ describe('ItemOperation', () => {
})
it('should stop propagation when clicking menu actions', async () => {
const user = userEvent.setup()
const onParentClick = vi.fn()
const togglePin = vi.fn()
render(
<div onClick={onParentClick}>
<ItemOperation
itemName="My App"
isPinned={false}
isShowDelete
togglePin={togglePin}
onDelete={vi.fn()}
/>
<ItemOperation itemName="My App" isPinned={false} togglePin={togglePin} />
</div>,
)
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
await user.click(await screen.findByText('explore.sidebar.action.pin'))
expect(togglePin).toHaveBeenCalledTimes(1)
expect(onParentClick).not.toHaveBeenCalled()
@@ -16,9 +16,7 @@ type IItemOperationProps = {
isPinned: boolean
isShowRenameConversation?: boolean
onRenameConversation?: () => void
isShowDelete: boolean
togglePin: () => void
onDelete: () => void
}
function ItemOperation({
@@ -28,8 +26,6 @@ function ItemOperation({
togglePin,
isShowRenameConversation,
onRenameConversation,
isShowDelete,
onDelete,
}: IItemOperationProps) {
const { t } = useTranslation(['explore'])
const { t: tCommon } = useTranslation(['common'])
@@ -79,30 +75,6 @@ function ItemOperation({
<span className={s.actionName}>{t(($) => $['sidebar.action.rename'])}</span>
</DropdownMenuItem>
)}
{isShowDelete && (
<DropdownMenuItem
className={cn(
s.actionItem,
s.deleteActionItem,
'gap-2 px-3 data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive',
)}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<span
aria-hidden
className={cn(
s.deleteActionItemChild,
'i-ri-delete-bin-line size-4 shrink-0 text-inherit',
)}
/>
<span className={cn(s.actionName, s.deleteActionItemChild, 'text-inherit')}>
{t(($) => $['sidebar.action.delete'])}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
@@ -7,7 +7,3 @@
.actionName {
@apply text-sm text-text-secondary;
}
.deleteActionItem:hover .deleteActionItemChild {
@apply text-red-500;
}
@@ -53,7 +53,6 @@ const {
mockIsAgentV2Enabled,
mockSwitchWorkspace,
mockToastSuccess,
mockUninstall,
mockUpdatePinStatus,
} = vi.hoisted(() => ({
mockFetchNextInstalledAppsPage: vi.fn(),
@@ -61,7 +60,6 @@ const {
mockIsAgentV2Enabled: vi.fn(() => true),
mockSwitchWorkspace: vi.fn(),
mockToastSuccess: vi.fn(),
mockUninstall: vi.fn(),
mockUpdatePinStatus: vi.fn(),
}))
const mockStepByStepTour = vi.hoisted(() => {
@@ -340,11 +338,6 @@ vi.mock('@/service/console', async (importOriginal) => {
}),
},
byInstalledAppId: {
delete: {
mutationOptions: () => ({
mutationFn: (input: unknown) => mockUninstall(input),
}),
},
patch: {
mutationOptions: () => ({
mutationFn: (input: unknown) => mockUpdatePinStatus(input),
@@ -474,7 +467,6 @@ const createInstalledApp = (
app_owner_tenant_id: overrides.app_owner_tenant_id ?? 'tenant-1',
editable: overrides.editable ?? true,
last_used_at: overrides.last_used_at ?? null,
uninstallable: overrides.uninstallable ?? false,
is_pinned: overrides.is_pinned ?? false,
app: {
id: overrides.app?.id ?? 'app-1',
@@ -676,7 +668,6 @@ describe('MainNav', () => {
}
},
)
mockUninstall.mockResolvedValue(undefined)
mockUpdatePinStatus.mockResolvedValue({ result: 'success', message: 'updated' })
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
})
@@ -1694,10 +1685,9 @@ describe('MainNav', () => {
expect(screen.getByText('Alpha App')).toBeInTheDocument()
})
it('updates pin status and reuses the existing delete confirmation for installed web apps', async () => {
it('updates pin status for installed web apps', async () => {
const user = userEvent.setup()
mockInstalledApps = [createInstalledApp()]
mockUninstall.mockResolvedValue(undefined)
mockUpdatePinStatus.mockResolvedValue(undefined)
renderMainNav()
@@ -1714,19 +1704,5 @@ describe('MainNav', () => {
body: { is_pinned: true },
})
})
await user.hover(screen.getByText('Alpha App'))
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*Alpha App/ }),
)
await user.click(await screen.findByText('explore.sidebar.action.delete'))
await user.click(await screen.findByText('common.operation.confirm'))
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith({
params: { installed_app_id: 'installed-1' },
})
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
})
})
})
@@ -10,7 +10,6 @@ import WebAppsSection from '../web-apps-section'
const service = vi.hoisted(() => ({
list: vi.fn<() => Promise<InstalledAppListResponse>>(),
remove: vi.fn<() => Promise<void>>(),
pin: vi.fn<() => Promise<void>>(),
}))
@@ -32,7 +31,6 @@ vi.mock('@/service/console', () => ({
}),
},
byInstalledAppId: {
delete: { mutationOptions: () => ({ mutationFn: service.remove }) },
patch: { mutationOptions: () => ({ mutationFn: service.pin }) },
},
},
@@ -45,7 +43,6 @@ function createApp(index: number): InstalledAppResponse {
app_owner_tenant_id: 'tenant-1',
editable: true,
last_used_at: null,
uninstallable: false,
is_pinned: index === 0,
app: {
id: `app-${index}`,
@@ -93,7 +90,6 @@ async function renderSection(count = 40) {
beforeEach(() => {
vi.clearAllMocks()
service.remove.mockResolvedValue(undefined)
service.pin.mockResolvedValue(undefined)
})
@@ -13,7 +13,6 @@ vi.mock('@tanstack/react-virtual')
const service = vi.hoisted(() => ({
list: vi.fn<() => Promise<InstalledAppListResponse>>(),
remove: vi.fn<() => Promise<void>>(),
pin: vi.fn<() => Promise<void>>(),
}))
@@ -35,7 +34,6 @@ vi.mock('@/service/console', () => ({
}),
},
byInstalledAppId: {
delete: { mutationOptions: () => ({ mutationFn: service.remove }) },
patch: { mutationOptions: () => ({ mutationFn: service.pin }) },
},
},
@@ -48,7 +46,6 @@ function createApp(index: number): InstalledAppResponse {
app_owner_tenant_id: 'tenant-1',
editable: true,
last_used_at: null,
uninstallable: false,
is_pinned: index === 0,
app: {
id: `app-${index}`,
@@ -84,7 +81,6 @@ async function renderSection(count = 2) {
beforeEach(() => {
vi.clearAllMocks()
service.remove.mockResolvedValue(undefined)
service.pin.mockResolvedValue(undefined)
})
@@ -107,37 +103,39 @@ it('leaves only the web apps fallback on the server without running its client p
expect(onBrowserBailout).toHaveBeenCalledOnce()
})
it('restores the menu trigger on cancel and blocks Escape during deletion', async () => {
const { user } = await renderSection()
const trigger = screen.getByRole('button', {
name: 'common.operation.moreActionsFor:{"name":"App 0"}',
})
await user.click(trigger)
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(trigger).toHaveFocus()
let finish!: () => void
service.remove.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
it.each([
{ index: 0, action: 'unpin', nextPinned: false },
{ index: 1, action: 'pin', nextPinned: true },
])(
'offers $action without a delete action for installed web apps',
async ({ index, action, nextPinned }) => {
const { user } = await renderSection()
await user.click(
screen.getByRole('button', {
name: `common.operation.moreActionsFor:{"name":"App ${index}"}`,
}),
)
await user.click(trigger)
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
try {
await user.keyboard('{Escape}')
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
} finally {
await act(async () => {
finish()
)
const pinAction = await screen.findByRole('menuitem', {
name: `explore.sidebar.action.${action}`,
})
}
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
})
expect(
screen.queryByRole('menuitem', { name: 'explore.sidebar.action.delete' }),
).not.toBeInTheDocument()
await user.click(pinAction)
await waitFor(() =>
expect(service.pin).toHaveBeenCalledWith(
{
params: { installed_app_id: `installed-${index}` },
body: { is_pinned: nextPinned },
},
expect.anything(),
),
)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
},
)
it('keeps the search input focused when a refresh removes all apps', async () => {
const { queryClient, user } = await renderSection(1)
@@ -154,21 +152,6 @@ it('keeps the search input focused when a refresh removes all apps', async () =>
expect(search).toHaveFocus()
})
it('returns focus to the section control after deleting the final app', async () => {
const { queryClient, user } = await renderSection(1)
service.remove.mockImplementation(async () => {
service.list.mockResolvedValue({ installed_apps: [], has_more: false, next_cursor: null })
await queryClient.invalidateQueries({ queryKey: ['installed-apps'] })
})
await user.click(
screen.getByRole('button', { name: 'common.operation.moreActionsFor:{"name":"App 0"}' }),
)
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'explore.sidebar.webApps' })).toHaveFocus()
})
it('keeps focus when clearing empty search results after the unfiltered cache expires', async () => {
const { queryClient, user } = await renderSection()
const toggle = screen.getByRole('button', { name: 'common.operation.search' })
@@ -5,15 +5,6 @@ import type {
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import type { InfiniteData } from '@tanstack/react-query'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { IconButton } from '@langgenius/dify-ui/icon-button'
@@ -70,14 +61,12 @@ const WebAppsSectionContent = () => {
const { t } = useTranslation(['common', 'explore'])
const pathname = usePathname()
const scrollRef = useRef<HTMLDivElement>(null)
const sectionToggleRef = useRef<HTMLButtonElement>(null)
const searchFocusRequestedRef = useRef(false)
const sectionLabelId = useId()
const [lastFocusedAppId, setLastFocusedAppId] = useState<string | null>(null)
const [appsExpanded, setAppsExpanded] = useState(true)
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
const [uninstallDialogAppId, setUninstallDialogAppId] = useState<string | null>(null)
const normalizedSearchText = searchText.trim()
const installedAppsQuery = useInfiniteQuery(
@@ -97,9 +86,6 @@ const WebAppsSectionContent = () => {
}),
)
const installedApps = installedAppsQuery.data ?? emptyInstalledApps
const uninstallAppMutation = useMutation(
consoleQuery.installedApps.byInstalledAppId.delete.mutationOptions(),
)
const updatePinStatusMutation = useMutation(
consoleQuery.installedApps.byInstalledAppId.patch.mutationOptions(),
)
@@ -176,22 +162,6 @@ const WebAppsSectionContent = () => {
input.focus()
}, [])
const handleDelete = () => {
if (!uninstallDialogAppId) return
uninstallAppMutation.mutate(
{
params: { installed_app_id: uninstallDialogAppId },
},
{
onSuccess: () => {
setUninstallDialogAppId(null)
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
},
},
)
}
const handleUpdatePinStatus = (id: string, isPinned: boolean) => {
updatePinStatusMutation.mutate(
{
@@ -211,9 +181,7 @@ const WebAppsSectionContent = () => {
!installedAppsQuery.isPlaceholderData &&
installedApps.length === 0 &&
!normalizedSearchText &&
!searchVisible &&
uninstallDialogAppId === null &&
!uninstallAppMutation.isSuccess
!searchVisible
)
return null
@@ -223,10 +191,7 @@ const WebAppsSectionContent = () => {
onOpenChange={setAppsExpanded}
className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_auto_minmax(0,1fr)]"
>
<CollapsibleTrigger
ref={sectionToggleRef}
className="group/collapsible col-start-1 row-start-1 my-1 ml-2 flex min-h-6 w-fit min-w-0 touch-manipulation items-center justify-start gap-0 rounded-md px-2 py-1 text-left system-sm-medium text-text-tertiary outline-hidden select-none hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<CollapsibleTrigger className="group/collapsible col-start-1 row-start-1 my-1 ml-2 flex min-h-6 w-fit min-w-0 touch-manipulation items-center justify-start gap-0 rounded-md px-2 py-1 text-left system-sm-medium text-text-tertiary outline-hidden select-none hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid">
<span id={sectionLabelId} className="system-xs-medium-uppercase">
{t(($) => $['sidebar.webApps'], { ns: 'explore' })}
</span>
@@ -332,10 +297,6 @@ const WebAppsSectionContent = () => {
app={row.app}
isSelected={isInstalledAppPath(pathname, row.app.id)}
onTogglePin={handleUpdatePinStatus}
onDelete={(id) => {
uninstallAppMutation.reset()
setUninstallDialogAppId(id)
}}
/>
)}
</li>
@@ -383,40 +344,6 @@ const WebAppsSectionContent = () => {
</ScrollAreaScrollbar>
</ScrollArea>
</CollapsiblePanel>
<AlertDialog
open={uninstallDialogAppId !== null}
onOpenChange={(open, details) => {
if (uninstallAppMutation.isPending) {
details.cancel()
return
}
if (!open) setUninstallDialogAppId(null)
}}
>
<AlertDialogContent
finalFocus={() => (uninstallAppMutation.isSuccess ? sectionToggleRef.current : true)}
>
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t(($) => $['sidebar.delete.title'], { ns: 'explore' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['sidebar.delete.content'], { ns: 'explore' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={uninstallAppMutation.isPending}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={uninstallAppMutation.isPending}
onClick={handleDelete}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Collapsible>
)
}
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "تثبيت",
"sidebar.action.rename": "إعادة تسمية",
"sidebar.action.unpin": "إلغاء التثبيت",
"sidebar.delete.content": "هل أنت متأكد أنك تريد حذف هذا التطبيق؟",
"sidebar.delete.title": "حذف التطبيق",
"sidebar.webApps": "تطبيقات الويب",
"tryApp.category": "الفئة",
"tryApp.createFromSampleApp": "إنشاء من هذا التطبيق النموذجي",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Sancaqla",
"sidebar.action.rename": "Adını dəyiş",
"sidebar.action.unpin": "Sancaqdan çıxar",
"sidebar.delete.content": "Bu tətbiqi silmək istədiyinizə əminsiniz?",
"sidebar.delete.title": "Tətbiqin silinməsi",
"sidebar.webApps": "Veb tətbiqləri",
"tryApp.category": "Kateqoriya",
"tryApp.createFromSampleApp": "Bu nümunə tətbiqdən yarat",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Anheften",
"sidebar.action.rename": "Umbenennen",
"sidebar.action.unpin": "Lösen",
"sidebar.delete.content": "Sind Sie sicher, dass Sie diese App löschen möchten?",
"sidebar.delete.title": "App löschen",
"sidebar.webApps": "Web-Apps",
"tryApp.category": "Kategorie",
"tryApp.createFromSampleApp": "Aus dieser Beispiel-App erstellen",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Pin",
"sidebar.action.rename": "Rename",
"sidebar.action.unpin": "Unpin",
"sidebar.delete.content": "Are you sure you want to delete this app?",
"sidebar.delete.title": "Delete app",
"sidebar.webApps": "Web apps",
"tryApp.category": "Category",
"tryApp.createFromSampleApp": "Create from this sample app",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Anclar",
"sidebar.action.rename": "Renombrar",
"sidebar.action.unpin": "Desanclar",
"sidebar.delete.content": "¿Estás seguro de que quieres eliminar esta aplicación?",
"sidebar.delete.title": "Eliminar aplicación",
"sidebar.webApps": "Aplicaciones web",
"tryApp.category": "Categoría",
"tryApp.createFromSampleApp": "Crear desde esta aplicación de ejemplo",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "سنجاق کردن",
"sidebar.action.rename": "تغییر نام",
"sidebar.action.unpin": "برداشتن سنجاق",
"sidebar.delete.content": "آیا مطمئن هستید که می‌خواهید این برنامه را حذف کنید؟",
"sidebar.delete.title": "حذف برنامه",
"sidebar.webApps": "برنامه‌های وب",
"tryApp.category": "دسته‌بندی",
"tryApp.createFromSampleApp": "از این برنامه نمونه ایجاد کنید",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Épingle",
"sidebar.action.rename": "Renommer",
"sidebar.action.unpin": "Détacher",
"sidebar.delete.content": "Êtes-vous sûr de vouloir supprimer cette application ?",
"sidebar.delete.title": "Supprimer l'application",
"sidebar.webApps": "Applications web",
"tryApp.category": "Catégorie",
"tryApp.createFromSampleApp": "Créer à partir de cette application exemple",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "पिन करें",
"sidebar.action.rename": "नाम बदलें",
"sidebar.action.unpin": "पिन हटाएँ",
"sidebar.delete.content": "क्या आप वाकई इस ऐप को हटाना चाहते हैं?",
"sidebar.delete.title": "ऐप हटाएं",
"sidebar.webApps": "वेब ऐप्स",
"tryApp.category": "श्रेणी",
"tryApp.createFromSampleApp": "इस नमूना ऐप से बनाएं",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Sematkan",
"sidebar.action.rename": "Ganti nama",
"sidebar.action.unpin": "Lepaskan sematan",
"sidebar.delete.content": "Apakah Anda yakin ingin menghapus aplikasi ini?",
"sidebar.delete.title": "Hapus aplikasi",
"sidebar.webApps": "Aplikasi web",
"tryApp.category": "Kategori",
"tryApp.createFromSampleApp": "Buat dari aplikasi contoh ini",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Fissa",
"sidebar.action.rename": "Rinomina",
"sidebar.action.unpin": "Sblocca",
"sidebar.delete.content": "Sei sicuro di voler eliminare questa app?",
"sidebar.delete.title": "Elimina app",
"sidebar.webApps": "App web",
"tryApp.category": "Categoria",
"tryApp.createFromSampleApp": "Crea da questa app di esempio",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "ピン留め",
"sidebar.action.rename": "名前変更",
"sidebar.action.unpin": "ピン留め解除",
"sidebar.delete.content": "このアプリを削除してもよろしいですか?",
"sidebar.delete.title": "アプリを削除",
"sidebar.webApps": "Webアプリ",
"tryApp.category": "カテゴリー",
"tryApp.createFromSampleApp": "テンプレートから作成",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "고정",
"sidebar.action.rename": "이름 변경",
"sidebar.action.unpin": "고정 해제",
"sidebar.delete.content": "이 앱을 삭제해도 괜찮습니까?",
"sidebar.delete.title": "앱 삭제",
"sidebar.webApps": "웹 앱",
"tryApp.category": "카테고리",
"tryApp.createFromSampleApp": "이 샘플 앱에서 만들기",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "ປັກໝຸດ",
"sidebar.action.rename": "ປ່ຽນຊື່",
"sidebar.action.unpin": "ຖອນປັກໝຸດ",
"sidebar.delete.content": "ເຈົ້າແນ່ໃຈບໍວ່າຕ້ອງການລຶບແອັບນີ້?",
"sidebar.delete.title": "ລຶບແອັບ",
"sidebar.webApps": "ແອັບເວັບ",
"tryApp.category": "ໝວດໝູ່",
"tryApp.createFromSampleApp": "ສ້າງຈາກແອັບຕົວຢ່າງນີ້",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Pin",
"sidebar.action.rename": "Rename",
"sidebar.action.unpin": "Unpin",
"sidebar.delete.content": "Are you sure you want to delete this app?",
"sidebar.delete.title": "Delete app",
"sidebar.webApps": "Web apps",
"tryApp.category": "Category",
"tryApp.createFromSampleApp": "Create from this sample app",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Przypnij",
"sidebar.action.rename": "Zmień nazwę",
"sidebar.action.unpin": "Odepnij",
"sidebar.delete.content": "Czy na pewno chcesz usunąć tę aplikację?",
"sidebar.delete.title": "Usuń aplikację",
"sidebar.webApps": "Aplikacje internetowe",
"tryApp.category": "Kategoria",
"tryApp.createFromSampleApp": "Utwórz z tej przykładowej aplikacji",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Fixar",
"sidebar.action.rename": "Renomear",
"sidebar.action.unpin": "Desafixar",
"sidebar.delete.content": "Tem certeza de que deseja excluir este aplicativo?",
"sidebar.delete.title": "Excluir aplicativo",
"sidebar.webApps": "Aplicativos web",
"tryApp.category": "Categoria",
"tryApp.createFromSampleApp": "Criar a partir deste aplicativo de exemplo",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Fixați",
"sidebar.action.rename": "Redenumire",
"sidebar.action.unpin": "Deblocați",
"sidebar.delete.content": "Sunteți sigur că doriți să ștergeți această aplicație?",
"sidebar.delete.title": "Ștergeți aplicația",
"sidebar.webApps": "Aplicații web",
"tryApp.category": "Categorie",
"tryApp.createFromSampleApp": "Creați din această aplicație exemplu",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Закрепить",
"sidebar.action.rename": "Переименовать",
"sidebar.action.unpin": "Открепить",
"sidebar.delete.content": "Вы уверены, что хотите удалить это приложение?",
"sidebar.delete.title": "Удалить приложение",
"sidebar.webApps": "Веб-приложения",
"tryApp.category": "Категория",
"tryApp.createFromSampleApp": "Создать из этого примера приложения",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Pripni",
"sidebar.action.rename": "Preimenuj",
"sidebar.action.unpin": "Odpni",
"sidebar.delete.content": "Ali ste prepričani, da želite izbrisati to aplikacijo?",
"sidebar.delete.title": "Izbriši aplikacijo",
"sidebar.webApps": "Spletne aplikacije",
"tryApp.category": "Kategorija",
"tryApp.createFromSampleApp": "Ustvari iz te vzorčne aplikacije",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "เข็มกลัด",
"sidebar.action.rename": "ตั้งชื่อใหม่",
"sidebar.action.unpin": "ปลดหมุด",
"sidebar.delete.content": "คุณแน่ใจหรือไม่ว่าต้องการลบแอปนี้?",
"sidebar.delete.title": "ลบแอป",
"sidebar.webApps": "แอปเว็บ",
"tryApp.category": "หมวดหมู่",
"tryApp.createFromSampleApp": "สร้างจากแอปตัวอย่างนี้",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Sabitle",
"sidebar.action.rename": "Yeniden Adlandır",
"sidebar.action.unpin": "Sabitlemeyi Kaldır",
"sidebar.delete.content": "Bu uygulamayı silmek istediğinizden emin misiniz?",
"sidebar.delete.title": "Uygulamayı sil",
"sidebar.webApps": "Web uygulamaları",
"tryApp.category": "Kategori",
"tryApp.createFromSampleApp": "Bu örnek uygulamadan oluştur",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Закріпити",
"sidebar.action.rename": "Перейменувати",
"sidebar.action.unpin": "Відкріпити",
"sidebar.delete.content": "Ви впевнені, що хочете видалити цю програму?",
"sidebar.delete.title": "Видалити додаток",
"sidebar.webApps": "Веб-додатки",
"tryApp.category": "Категорія",
"tryApp.createFromSampleApp": "Створити з цього зразка додатку",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "Ghim",
"sidebar.action.rename": "Đổi tên",
"sidebar.action.unpin": "Bỏ ghim",
"sidebar.delete.content": "Bạn có chắc chắn muốn xóa ứng dụng này không?",
"sidebar.delete.title": "Xóa ứng dụng",
"sidebar.webApps": "Ứng dụng web",
"tryApp.category": "Danh mục",
"tryApp.createFromSampleApp": "Tạo từ ứng dụng mẫu này",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "置顶",
"sidebar.action.rename": "重命名",
"sidebar.action.unpin": "取消置顶",
"sidebar.delete.content": "您确定要删除此程序吗?",
"sidebar.delete.title": "删除程序",
"sidebar.webApps": "WEB APPS",
"tryApp.category": "分类",
"tryApp.createFromSampleApp": "从此模板创建应用",
-2
View File
@@ -33,8 +33,6 @@
"sidebar.action.pin": "置頂",
"sidebar.action.rename": "重新命名",
"sidebar.action.unpin": "取消置頂",
"sidebar.delete.content": "您確定要刪除此程式嗎?",
"sidebar.delete.title": "刪除程式",
"sidebar.webApps": "WEB APPS",
"tryApp.category": "分類",
"tryApp.createFromSampleApp": "從此範本建立應用",
-16
View File
@@ -717,22 +717,6 @@ export function createConsoleQuery(consoleClient: ConsoleClient) {
},
},
},
delete: {
mutationOptions: {
onSuccess: (_response, variables, _onMutateResult, context) => {
context.client.removeQueries({
queryKey: consoleQuery.installedApps.byInstalledAppId.get.queryKey({
input: {
params: variables.params,
},
}),
})
context.client.invalidateQueries({
queryKey: consoleQuery.installedApps.get.key(),
})
},
},
},
patch: {
mutationOptions: {
onSuccess: (_response, variables, _onMutateResult, context) => {