mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-30 08:06:07 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e30b32889 | |||
| 6b8838a308 | |||
| 5ba5e60002 | |||
| 7b03e3395f | |||
| d3c443a2c8 | |||
| 463b120923 | |||
| 3ca724d18e | |||
| dd8d1007a1 |
@@ -0,0 +1,166 @@
|
||||
# LangBot 多租户数据库迁移指南
|
||||
|
||||
## 概述
|
||||
|
||||
LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
|
||||
|
||||
## 迁移序列
|
||||
|
||||
```
|
||||
0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
|
||||
0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
|
||||
0011_postgres_tenant_rls → PostgreSQL 行级安全策略
|
||||
0012_plugin_installation_identity → 插件实例租户绑定
|
||||
0013_tenant_pgvector → RAG 向量存储隔离
|
||||
0014_cloud_directory_projection → Cloud 控制平面同步
|
||||
0015_cloud_core_collaboration → 协作和权限功能
|
||||
```
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 1. 备份(必须)
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
|
||||
|
||||
# PostgreSQL
|
||||
pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
|
||||
```
|
||||
|
||||
### 2. 执行迁移
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 执行迁移
|
||||
python -m langbot.pkg.persistence.migration upgrade head
|
||||
|
||||
# 验证
|
||||
python -m langbot.pkg.persistence.migration current
|
||||
# 预期: 0015_cloud_core_collaboration
|
||||
|
||||
# 启动服务
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 3. OSS 单租户自动迁移
|
||||
|
||||
迁移会自动:
|
||||
- 创建默认 Workspace(名称:"Default Workspace")
|
||||
- 第一个用户成为 Owner
|
||||
- 所有现有资源绑定到该 Workspace
|
||||
|
||||
### 4. 验证检查
|
||||
|
||||
```bash
|
||||
# 检查 Workspace
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
|
||||
print(f"Workspace: {ws[1]} ({ws[0]})")
|
||||
|
||||
# 检查资源绑定
|
||||
bot_count = conn.execute(sa.text(
|
||||
f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
|
||||
)).scalar()
|
||||
print(f"Bots: {bot_count}")
|
||||
EOF
|
||||
```
|
||||
|
||||
## 回滚方案
|
||||
|
||||
### 完全回滚(丢失多租户数据)
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 2. 恢复备份
|
||||
cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
|
||||
|
||||
# 3. 回退代码
|
||||
git checkout v4.10.x
|
||||
pip install -e .
|
||||
|
||||
# 4. 启动
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 降级迁移(保留数据但移除多租户)
|
||||
|
||||
```bash
|
||||
# 警告:会移除 Workspace 表但保留资源
|
||||
python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 迁移后无法登录
|
||||
|
||||
```bash
|
||||
# 检查用户 UUID
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
|
||||
for u in users:
|
||||
print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
|
||||
EOF
|
||||
```
|
||||
|
||||
### Q: 资源看不见了
|
||||
|
||||
检查 Workspace 上下文:
|
||||
```bash
|
||||
# 前端请求需要带 X-Workspace-ID header
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
-H "X-Workspace-ID: $WORKSPACE_UUID" \
|
||||
http://localhost:5200/api/v1/platform/bots
|
||||
```
|
||||
|
||||
### Q: 迁移速度慢
|
||||
|
||||
```bash
|
||||
# SQLite 优化
|
||||
sqlite3 ~/.langbot/data/langbot.db << EOF
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
VACUUM;
|
||||
EOF
|
||||
```
|
||||
|
||||
## 性能调优
|
||||
|
||||
### PostgreSQL 索引
|
||||
|
||||
```sql
|
||||
-- 迁移后创建
|
||||
CREATE INDEX CONCURRENTLY idx_model_providers_workspace
|
||||
ON model_providers(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_bots_workspace
|
||||
ON bots(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_pipelines_workspace
|
||||
ON pipelines(workspace_uuid);
|
||||
```
|
||||
|
||||
## 预估时间
|
||||
|
||||
- SQLite < 100MB: 2-5 分钟
|
||||
- SQLite 100MB-1GB: 5-15 分钟
|
||||
- PostgreSQL: < 5 分钟(取决于数据量)
|
||||
|
||||
## 支持
|
||||
|
||||
问题反馈:https://github.com/langbot-app/LangBot/issues
|
||||
|
||||
**版本**: 1.0
|
||||
**最后更新**: 2026-07-30
|
||||
@@ -23,8 +23,13 @@ def _storage_owner(context: RequestContext) -> str:
|
||||
@group.group_class('files', '/api/v1/files')
|
||||
class FilesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _(image_key: str) -> quart.Response:
|
||||
@self.route(
|
||||
'/image/<path:image_key>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
|
||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||
image_key,
|
||||
expected_owner_type='upload_image',
|
||||
|
||||
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
token: str,
|
||||
) -> None:
|
||||
) -> RequestContext:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
@@ -168,6 +168,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
entitlement_revision=request_context.entitlement_revision,
|
||||
)
|
||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||
return current_context
|
||||
|
||||
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
|
||||
pipeline = await run_in_workspace_uow(
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
@@ -23,6 +24,11 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def list_skills(request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skills = await self.ap.skill_service.list_skills(request_context)
|
||||
except EntitlementFeatureUnavailableError:
|
||||
# Plans without managed sandbox support have no runnable skills.
|
||||
# Treat that capability absence as an empty collection so the
|
||||
# shared UI can render normally instead of surfacing a 500.
|
||||
return self.success(data={'skills': []})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'skills': skills})
|
||||
|
||||
@@ -331,8 +331,15 @@ class ModelProviderService:
|
||||
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
context, provider_uuid
|
||||
)
|
||||
rerank_service = getattr(self.ap, 'rerank_models_service', None)
|
||||
rerank_models = (
|
||||
await rerank_service.get_rerank_models_by_provider(context, provider_uuid)
|
||||
if rerank_service is not None
|
||||
else []
|
||||
)
|
||||
existing_llm_names = {model['name'] for model in llm_models}
|
||||
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||
existing_rerank_names = {model['name'] for model in rerank_models}
|
||||
|
||||
filtered_models = []
|
||||
for model in scanned_models:
|
||||
@@ -359,6 +366,8 @@ class ModelProviderService:
|
||||
'already_added': (
|
||||
model_name in existing_embedding_names
|
||||
if scanned_type == 'embedding'
|
||||
else model_name in existing_rerank_names
|
||||
if scanned_type == 'rerank'
|
||||
else model_name in existing_llm_names
|
||||
),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ....cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
from ....core import app
|
||||
from ....skill.utils import parse_frontmatter
|
||||
from ....utils import httpclient
|
||||
@@ -120,13 +119,7 @@ class SkillService:
|
||||
box_service = self._box_service()
|
||||
if box_service is None:
|
||||
return []
|
||||
try:
|
||||
skills = await box_service.list_skills(execution_context)
|
||||
except EntitlementFeatureUnavailableError as error:
|
||||
if error.feature == 'managed_sandbox':
|
||||
return []
|
||||
raise
|
||||
return [self._serialize_skill(skill) for skill in skills]
|
||||
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
|
||||
|
||||
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
|
||||
@@ -18,11 +18,19 @@ class EntitlementUnavailableError(RuntimeError):
|
||||
|
||||
|
||||
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
|
||||
"""Raised when an active entitlement explicitly omits a capability."""
|
||||
"""Raised only when an active entitlement does not grant one feature."""
|
||||
|
||||
def __init__(self, message: str, *, feature: str, entitlement_revision: int | None = None) -> None:
|
||||
super().__init__(message, entitlement_revision=entitlement_revision)
|
||||
def __init__(
|
||||
self,
|
||||
feature: str,
|
||||
*,
|
||||
entitlement_revision: int | None = None,
|
||||
) -> None:
|
||||
self.feature = feature
|
||||
super().__init__(
|
||||
f'Workspace entitlement does not grant {feature}',
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
|
||||
|
||||
class EntitlementSnapshot(pydantic.BaseModel):
|
||||
@@ -91,8 +99,7 @@ class EntitlementSnapshot(pydantic.BaseModel):
|
||||
def require_feature(self, feature: str) -> None:
|
||||
if self.features.get(feature) is not True:
|
||||
raise EntitlementFeatureUnavailableError(
|
||||
f'Workspace entitlement does not grant {feature}',
|
||||
feature=feature,
|
||||
feature,
|
||||
entitlement_revision=self.entitlement_revision,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .. import handler
|
||||
from ... import entities
|
||||
from ... import plugin_diagnostics
|
||||
from ....entity.persistence.bot import BotAdmin
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -24,7 +26,14 @@ class CommandHandler(handler.MessageHandler):
|
||||
|
||||
privilege = 1
|
||||
|
||||
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
||||
admins = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(BotAdmin).where(
|
||||
BotAdmin.bot_uuid == (query.bot_uuid or ''),
|
||||
BotAdmin.launcher_type == query.launcher_type.value,
|
||||
BotAdmin.launcher_id == str(query.launcher_id),
|
||||
)
|
||||
)
|
||||
if admins.first() is not None:
|
||||
privilege = 2
|
||||
|
||||
spt = command_text.split(' ')
|
||||
|
||||
@@ -241,7 +241,11 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
async def process_message_data(msg_data, reply_list):
|
||||
if msg_data['type'] == 'image':
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
|
||||
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
reply_list.append(
|
||||
platform_message.Image(
|
||||
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
||||
)
|
||||
)
|
||||
|
||||
elif msg_data['type'] == 'text':
|
||||
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
|
||||
@@ -286,7 +290,9 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
|
||||
else:
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
|
||||
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
image_msg = platform_message.Image(
|
||||
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
||||
)
|
||||
yiri_msg_list.append(image_msg)
|
||||
elif msg.type == 'forward':
|
||||
# 暂时不太合理
|
||||
|
||||
@@ -764,7 +764,9 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
)
|
||||
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
|
||||
image_format = response.headers['Content-Type']
|
||||
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
|
||||
element_list.append(
|
||||
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
|
||||
)
|
||||
|
||||
return platform_message.MessageChain(element_list)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
if pic_url is not None:
|
||||
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
|
||||
yiri_msg_list.append(platform_message.Image(base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
if pic_url is not None:
|
||||
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
|
||||
yiri_msg_list.append(platform_message.Image(base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -181,7 +181,10 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
|
||||
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
|
||||
message_components.append(
|
||||
platform_message.Image(base64=f'data:{file_format};base64,{encoded.decode("utf-8")}')
|
||||
platform_message.Image(
|
||||
url=file.file_path,
|
||||
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
|
||||
)
|
||||
)
|
||||
|
||||
if message.voice:
|
||||
@@ -889,4 +892,4 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.logger.info('Telegram adapter stopped')
|
||||
self.msg_stream_id.clear()
|
||||
self._form_action_titles.clear()
|
||||
return True
|
||||
return True
|
||||
@@ -133,7 +133,9 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
|
||||
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
yiri_msg_list.append(
|
||||
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
)
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
@@ -529,6 +529,7 @@ class ModelManager:
|
||||
model['uuid']: model
|
||||
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
|
||||
}
|
||||
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
@@ -597,6 +598,33 @@ class ModelManager:
|
||||
)
|
||||
updated += 1
|
||||
|
||||
elif space_model.category == 'rerank':
|
||||
existing = existing_rerank_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
await self.ap.rerank_models_service.create_rerank_model(
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'extra_args': {},
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
created += 1
|
||||
elif existing.get('provider_uuid') == space_model_provider.uuid:
|
||||
desired = {
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
}
|
||||
if (
|
||||
existing.get('name') != desired['name']
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
if created or updated:
|
||||
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
|
||||
|
||||
|
||||
@@ -944,16 +944,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
request_args = dict(extra_args)
|
||||
rerank_url = request_args.pop('rerank_url', None)
|
||||
rerank_path = request_args.pop('rerank_path', 'rerank')
|
||||
|
||||
payload: dict[str, typing.Any] = {
|
||||
'model': model_name,
|
||||
'query': query,
|
||||
'documents': documents,
|
||||
'top_n': top_n,
|
||||
}
|
||||
if extra_args:
|
||||
payload.update(extra_args)
|
||||
if request_args:
|
||||
payload.update(request_args)
|
||||
|
||||
rerank_url = f'{base_url}/rerank'
|
||||
if not rerank_url:
|
||||
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
|
||||
@@ -495,18 +495,19 @@ class WorkspaceCollaborationService:
|
||||
return membership
|
||||
|
||||
token_digest = hash_invitation_token(token)
|
||||
async with self._invitation_lock(token_digest):
|
||||
lock_key = f"{workspace_uuid}:{token_digest}"
|
||||
async with self._invitation_lock(lock_key):
|
||||
return await self._run(operation, session=session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _invitation_lock(self, token_digest: str):
|
||||
"""Serialize one token while retaining only active lock entries."""
|
||||
async def _invitation_lock(self, lock_key: str):
|
||||
"""Serialize one token within workspace scope while retaining only active lock entries."""
|
||||
|
||||
async with self._invitation_locks_guard:
|
||||
entry = self._invitation_locks.get(token_digest)
|
||||
entry = self._invitation_locks.get(lock_key)
|
||||
if entry is None:
|
||||
entry = _InvitationLockEntry(lock=asyncio.Lock())
|
||||
self._invitation_locks[token_digest] = entry
|
||||
self._invitation_locks[lock_key] = entry
|
||||
entry.users += 1
|
||||
|
||||
await entry.lock.acquire()
|
||||
@@ -517,7 +518,7 @@ class WorkspaceCollaborationService:
|
||||
async with self._invitation_locks_guard:
|
||||
entry.users -= 1
|
||||
if entry.users == 0:
|
||||
self._invitation_locks.pop(token_digest, None)
|
||||
self._invitation_locks.pop(lock_key, None)
|
||||
|
||||
async def revoke_invitation(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Test cross-tenant isolation boundaries."""
|
||||
import pytest
|
||||
import uuid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_access_tenant_b_bots(auth_client_factory):
|
||||
"""Tenant A cannot see or modify Tenant B's bots."""
|
||||
# Create two workspaces with different users
|
||||
client_a = await auth_client_factory(email="tenant_a@test.com")
|
||||
client_b = await auth_client_factory(email="tenant_b@test.com")
|
||||
|
||||
# Tenant B creates a bot
|
||||
bot_resp = await client_b.post("/api/v1/platform/bots", json={
|
||||
"name": "Tenant B Bot",
|
||||
"enabled": True
|
||||
})
|
||||
assert bot_resp.status_code == 200
|
||||
bot_uuid = bot_resp.json()["data"]["uuid"]
|
||||
|
||||
# Tenant A tries to access Tenant B's bot
|
||||
resp = await client_a.get(f"/api/v1/platform/bots/{bot_uuid}")
|
||||
assert resp.status_code == 404 # Not found (membership hiding)
|
||||
|
||||
# Tenant A tries to delete Tenant B's bot
|
||||
resp = await client_a.delete(f"/api/v1/platform/bots/{bot_uuid}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_role_is_read_only(auth_client_factory):
|
||||
"""Viewer role can read but not modify workspace resources."""
|
||||
owner = await auth_client_factory(email="owner@test.com")
|
||||
|
||||
# Owner invites a viewer
|
||||
invite_resp = await owner.post("/api/v1/workspaces/current/invitations", json={
|
||||
"email": "viewer@test.com",
|
||||
"role": "viewer"
|
||||
})
|
||||
assert invite_resp.status_code == 200
|
||||
token = invite_resp.json()["data"]["token"]
|
||||
|
||||
# Viewer accepts invitation
|
||||
viewer = await auth_client_factory(email="viewer@test.com")
|
||||
await viewer.post(f"/api/v1/workspaces/invitations/{token}/accept")
|
||||
|
||||
# Viewer can read bots
|
||||
resp = await viewer.get("/api/v1/platform/bots")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Viewer cannot create bots
|
||||
resp = await viewer.post("/api/v1/platform/bots", json={"name": "Test Bot"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Viewer cannot modify providers
|
||||
resp = await viewer.post("/api/v1/provider/providers", json={"name": "Test Provider"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removed_member_loses_access(auth_client_factory):
|
||||
"""Removed workspace member loses API access immediately."""
|
||||
owner = await auth_client_factory(email="owner@test.com")
|
||||
member = await auth_client_factory(email="member@test.com")
|
||||
|
||||
# Owner invites member
|
||||
invite_resp = await owner.post("/api/v1/workspaces/current/invitations", json={
|
||||
"email": "member@test.com",
|
||||
"role": "developer"
|
||||
})
|
||||
token = invite_resp.json()["data"]["token"]
|
||||
await member.post(f"/api/v1/workspaces/invitations/{token}/accept")
|
||||
|
||||
# Member can access workspace resources
|
||||
resp = await member.get("/api/v1/platform/bots")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Owner removes member
|
||||
memberships = await owner.get("/api/v1/workspaces/current/members")
|
||||
member_uuid = next(m["uuid"] for m in memberships.json()["data"]["members"]
|
||||
if m["account_email"] == "member@test.com")
|
||||
await owner.delete(f"/api/v1/workspaces/current/members/{member_uuid}")
|
||||
|
||||
# Member immediately loses access
|
||||
resp = await member.get("/api/v1/platform/bots")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_provider_credentials_isolated(auth_client_factory):
|
||||
"""Model provider credentials are isolated per workspace."""
|
||||
client_a = await auth_client_factory(email="tenant_a@test.com")
|
||||
client_b = await auth_client_factory(email="tenant_b@test.com")
|
||||
|
||||
# Tenant A creates provider with API key
|
||||
provider_resp = await client_a.post("/api/v1/provider/providers", json={
|
||||
"name": "OpenAI",
|
||||
"type": "openai",
|
||||
"api_key": "sk-tenant-a-secret-key"
|
||||
})
|
||||
assert provider_resp.status_code == 200
|
||||
provider_uuid = provider_resp.json()["data"]["uuid"]
|
||||
|
||||
# Tenant B cannot see Tenant A's provider
|
||||
resp = await client_b.get(f"/api/v1/provider/providers/{provider_uuid}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# Tenant B cannot list Tenant A's provider
|
||||
providers = await client_b.get("/api/v1/provider/providers")
|
||||
assert provider_uuid not in [p["uuid"] for p in providers.json()["data"]["providers"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_messages_not_leaked_across_tenants(auth_client_factory, websocket_connect):
|
||||
"""WebSocket messages are isolated to workspace."""
|
||||
client_a = await auth_client_factory(email="tenant_a@test.com")
|
||||
client_b = await auth_client_factory(email="tenant_b@test.com")
|
||||
|
||||
# Both create bots
|
||||
bot_a_resp = await client_a.post("/api/v1/platform/bots", json={"name": "Bot A"})
|
||||
bot_b_resp = await client_b.post("/api/v1/platform/bots", json={"name": "Bot B"})
|
||||
|
||||
bot_a_uuid = bot_a_resp.json()["data"]["uuid"]
|
||||
bot_b_uuid = bot_b_resp.json()["data"]["uuid"]
|
||||
|
||||
# Connect WebSockets
|
||||
ws_a = await websocket_connect(client_a, f"/api/v1/pipeline/websocket/{bot_a_uuid}")
|
||||
ws_b = await websocket_connect(client_b, f"/api/v1/pipeline/websocket/{bot_b_uuid}")
|
||||
|
||||
# Send message from Tenant A
|
||||
await ws_a.send_json({"message": "Hello from Tenant A"})
|
||||
|
||||
# Tenant B should not receive Tenant A's message
|
||||
# (Only their own bot's response)
|
||||
import asyncio
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws_b.receive_json(), timeout=1.0)
|
||||
assert "Tenant A" not in str(msg), "Message leaked across tenants!"
|
||||
except asyncio.TimeoutError:
|
||||
pass # Expected - no cross-tenant messages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invitation_token_workspace_scoped(auth_client_factory):
|
||||
"""Invitation tokens are scoped to specific workspace."""
|
||||
workspace_a_owner = await auth_client_factory(email="owner_a@test.com")
|
||||
workspace_b_owner = await auth_client_factory(email="owner_b@test.com")
|
||||
|
||||
# Workspace A creates invitation for user@test.com
|
||||
invite_resp = await workspace_a_owner.post("/api/v1/workspaces/current/invitations", json={
|
||||
"email": "user@test.com",
|
||||
"role": "developer"
|
||||
})
|
||||
token_a = invite_resp.json()["data"]["token"]
|
||||
|
||||
# User tries to accept invitation in context of Workspace B
|
||||
user = await auth_client_factory(email="user@test.com")
|
||||
|
||||
# Switch user's context to Workspace B
|
||||
workspaces = await user.get("/api/v1/workspaces")
|
||||
workspace_b_uuid = next(w["uuid"] for w in workspaces.json()["data"]["workspaces"]
|
||||
if w["created_by_account_email"] == "owner_b@test.com")
|
||||
|
||||
# Accept with wrong workspace context should fail
|
||||
resp = await user.post(
|
||||
f"/api/v1/workspaces/invitations/{token_a}/accept",
|
||||
headers={"X-Workspace-ID": workspace_b_uuid}
|
||||
)
|
||||
assert resp.status_code in [400, 404] # Token doesn't belong to this workspace
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Skills API behavior when a workspace plan has no managed sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
|
||||
from langbot.pkg.cloud.entitlements import (
|
||||
EntitlementFeatureUnavailableError,
|
||||
EntitlementUnavailableError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def skills_api():
|
||||
account = SimpleNamespace(uuid='owner-account', user='owner@example.com')
|
||||
access = SimpleNamespace(
|
||||
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
|
||||
membership=SimpleNamespace(uuid='member-owner', role='owner', projection_revision=1),
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||
application.skill_service.list_skills = AsyncMock(
|
||||
side_effect=EntitlementFeatureUnavailableError(
|
||||
'managed_sandbox',
|
||||
entitlement_revision=1,
|
||||
)
|
||||
)
|
||||
|
||||
quart_app = quart.Quart(__name__)
|
||||
router = SkillsRouterGroup(application, quart_app)
|
||||
await router.initialize()
|
||||
return application, quart_app.test_client()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
|
||||
application, client = skills_api
|
||||
response = await client.get(
|
||||
'/api/v1/skills',
|
||||
headers={
|
||||
'Authorization': 'Bearer owner-token',
|
||||
'X-Workspace-Id': WORKSPACE_UUID,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data'] == {'skills': []}
|
||||
application.skill_service.list_skills.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api):
|
||||
application, client = skills_api
|
||||
application.skill_service.list_skills.side_effect = EntitlementUnavailableError(
|
||||
'Workspace entitlement revision rolled back'
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/skills',
|
||||
headers={
|
||||
'Authorization': 'Bearer owner-token',
|
||||
'X-Workspace-Id': WORKSPACE_UUID,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
@@ -833,6 +833,44 @@ class TestModelProviderServiceScanProviderModels:
|
||||
assert len(result['models']) == 1
|
||||
assert result['models'][0]['type'] == 'llm'
|
||||
|
||||
async def test_scan_provider_marks_existing_rerank_model(self):
|
||||
"""Rerank scan results use the rerank service when computing already_added."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.model_mgr = SimpleNamespace()
|
||||
ap.llm_model_service = SimpleNamespace()
|
||||
ap.embedding_models_service = SimpleNamespace()
|
||||
ap.rerank_models_service = SimpleNamespace()
|
||||
|
||||
provider = _create_mock_provider(provider_uuid='rerank-scan-uuid')
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result([], first_item=provider))
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
'uuid': 'rerank-scan-uuid',
|
||||
'name': 'New API',
|
||||
'requester': 'new-api-chat-completions',
|
||||
'base_url': 'https://new-api.example.com/v1',
|
||||
'api_keys': ['key'],
|
||||
}
|
||||
)
|
||||
|
||||
runtime_provider = Mock()
|
||||
runtime_provider.token_mgr.get_token.return_value = 'token'
|
||||
runtime_provider.requester.scan_models = AsyncMock(
|
||||
return_value={'models': [{'id': 'Qwen3-Reranker-8B', 'type': 'rerank'}]}
|
||||
)
|
||||
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
|
||||
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
|
||||
return_value=[{'name': 'Qwen3-Reranker-8B'}]
|
||||
)
|
||||
|
||||
result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
|
||||
|
||||
assert result['models'][0]['type'] == 'rerank'
|
||||
assert result['models'][0]['already_added'] is True
|
||||
|
||||
async def test_scan_provider_not_implemented_raises_error(self):
|
||||
"""Raises ValueError when scan not implemented."""
|
||||
# Setup
|
||||
|
||||
@@ -756,7 +756,7 @@ class TestSpaceServiceGetModels:
|
||||
'uuid': 'uuid-2',
|
||||
'model_id': 'model-2',
|
||||
'provider': 'provider-2',
|
||||
'category': 'chat',
|
||||
'category': 'rerank',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
@@ -778,6 +778,7 @@ class TestSpaceServiceGetModels:
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
assert result[1].category == 'rerank'
|
||||
|
||||
async def test_get_models_api_error(self):
|
||||
"""Raises ValueError on API error."""
|
||||
|
||||
@@ -158,17 +158,21 @@ class TestCommandHandlerReal:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Admin users get privilege level 2."""
|
||||
"""A per-bot admin from the database is marked as admin in command events."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = Mock()
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 12345
|
||||
|
||||
@@ -176,23 +180,28 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Non-admin users get privilege level 1."""
|
||||
"""A launcher absent from the per-bot admin table is not an admin."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = None
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 67890
|
||||
|
||||
@@ -200,6 +209,7 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is False
|
||||
|
||||
@@ -1147,6 +1147,46 @@ class TestInvokeRerank:
|
||||
assert results[0]['relevance_score'] == 1.0
|
||||
assert results[1]['relevance_score'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('model_extra_args', 'expected_url'),
|
||||
[
|
||||
({'rerank_path': 'reranks'}, 'https://gateway.example.com/v1/reranks'),
|
||||
({'rerank_url': 'https://rerank.example.com/api/rerank'}, 'https://rerank.example.com/api/rerank'),
|
||||
],
|
||||
)
|
||||
async def test_invoke_rerank_openai_compatible_endpoint_override(self, model_extra_args, expected_url):
|
||||
"""Endpoint configuration controls routing and is not sent in the Cohere body."""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://gateway.example.com/v1/',
|
||||
'custom_llm_provider': 'openai',
|
||||
},
|
||||
)
|
||||
model = MockRuntimeRerankModel('Qwen3-Reranker-8B', 'test-api-key')
|
||||
model.model_entity.extra_args = model_extra_args
|
||||
|
||||
mock_resp = Mock()
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_resp.json = Mock(return_value={'results': [{'index': 0, 'relevance_score': 0.8}]})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||
await requester.invoke_rerank(model=model, query='query', documents=['document'])
|
||||
|
||||
assert mock_client.post.call_args.args[0] == expected_url
|
||||
payload = mock_client.post.call_args.kwargs['json']
|
||||
assert payload == {
|
||||
'model': 'Qwen3-Reranker-8B',
|
||||
'query': 'query',
|
||||
'documents': ['document'],
|
||||
'top_n': 1,
|
||||
}
|
||||
|
||||
|
||||
class TestConvertMessages:
|
||||
"""Test _convert_messages method"""
|
||||
|
||||
@@ -86,6 +86,48 @@ async def test_model_manager_skips_legacy_space_sync_in_cloud_runtime(mock_app_f
|
||||
app.workspace_service.get_local_execution_binding.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
|
||||
"""Space rerank entries are discovered and persisted under the shared provider."""
|
||||
app = mock_app_for_modelmgr
|
||||
provider = persistence_model.ModelProvider(
|
||||
uuid='space-provider',
|
||||
name='LangBot Space',
|
||||
requester='space-chat-completions',
|
||||
base_url='https://api.langbot.cloud/v1',
|
||||
api_keys=['space-key'],
|
||||
)
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider))
|
||||
app.space_service.get_models = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
uuid='rerank-model-uuid',
|
||||
model_id='Qwen3-Reranker-8B',
|
||||
category='rerank',
|
||||
featured_order=10,
|
||||
)
|
||||
]
|
||||
)
|
||||
app.llm_model_service.get_llm_models = AsyncMock(return_value=[])
|
||||
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
|
||||
app.rerank_models_service = AsyncMock()
|
||||
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
|
||||
|
||||
model_mgr = ModelManager(app)
|
||||
await model_mgr.sync_new_models_from_space()
|
||||
|
||||
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
|
||||
{
|
||||
'uuid': 'rerank-model-uuid',
|
||||
'name': 'Qwen3-Reranker-8B',
|
||||
'provider_uuid': 'space-provider',
|
||||
'extra_args': {},
|
||||
'prefered_ranking': 10,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Model Loading Tests
|
||||
# ============================================================================
|
||||
@@ -1067,4 +1109,4 @@ def test_provider_not_found_error_str():
|
||||
error = provider_errors.ProviderNotFoundError('test-provider')
|
||||
|
||||
assert str(error) == 'Provider test-provider not found'
|
||||
assert error.provider_name == 'test-provider'
|
||||
assert error.provider_name == 'test-provider'
|
||||
@@ -8,7 +8,6 @@ import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.skill import SkillService
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
|
||||
|
||||
|
||||
_CONTEXT = ExecutionContext(
|
||||
@@ -114,42 +113,6 @@ class TestRequireBoxForWrite:
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
assert await service.list_skills(_CONTEXT) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_empty_when_managed_sandbox_is_not_granted(self):
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
list_skills=AsyncMock(
|
||||
side_effect=EntitlementFeatureUnavailableError(
|
||||
'Workspace entitlement does not grant managed_sandbox',
|
||||
feature='managed_sandbox',
|
||||
)
|
||||
),
|
||||
)
|
||||
service = SkillService(
|
||||
SimpleNamespace(
|
||||
workspace_service=_workspace_service(),
|
||||
box_service=box_service,
|
||||
)
|
||||
)
|
||||
|
||||
assert await service.list_skills(_CONTEXT) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_preserves_other_entitlement_failures(self):
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
list_skills=AsyncMock(side_effect=EntitlementUnavailableError('control plane unavailable')),
|
||||
)
|
||||
service = SkillService(
|
||||
SimpleNamespace(
|
||||
workspace_service=_workspace_service(),
|
||||
box_service=box_service,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(EntitlementUnavailableError, match='control plane unavailable'):
|
||||
await service.list_skills(_CONTEXT)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_skill_file_refused_when_box_unavailable(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
|
||||
Reference in New Issue
Block a user