mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
Merge remote-tracking branch 'origin/dev/4.11.x' into dev/4.11.x
# Conflicts: # uv.lock # web/src/i18n/locales/en-US.ts # web/src/i18n/locales/ja-JP.ts # web/src/i18n/locales/zh-Hans.ts # web/src/i18n/locales/zh-Hant.ts
This commit is contained in:
@@ -20,6 +20,7 @@ from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
|
||||
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -28,15 +29,10 @@ WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||
|
||||
|
||||
class _PersistenceManager:
|
||||
class _PersistenceManager(PersistenceManager):
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
async def execute_async(self, *args, **kwargs):
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.execute(*args, **kwargs)
|
||||
await connection.commit()
|
||||
return result
|
||||
super().__init__(SimpleNamespace())
|
||||
self.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
|
||||
@staticmethod
|
||||
def serialize_model(model, data, masked_columns=None):
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
[
|
||||
{
|
||||
"name": "base-url",
|
||||
"label": {
|
||||
"en_US": "Base URL",
|
||||
"zh_Hans": "\u57fa\u7840 URL"
|
||||
},
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"default": "http://localhost:7860"
|
||||
},
|
||||
{
|
||||
"name": "api-key",
|
||||
"label": {
|
||||
"en_US": "API Key",
|
||||
"zh_Hans": "API \u5bc6\u94a5"
|
||||
},
|
||||
"type": "secret",
|
||||
"required": true,
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "flow-id",
|
||||
"label": {
|
||||
"en_US": "Flow ID",
|
||||
"zh_Hans": "\u6d41\u7a0b ID"
|
||||
},
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "advanced-settings",
|
||||
"label": {
|
||||
"en_US": "Advanced Settings",
|
||||
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Show input, output, and flow tweak controls.",
|
||||
"zh_Hans": "\u663e\u793a\u8f93\u5165\u3001\u8f93\u51fa\u548c\u6d41\u7a0b tweak \u8c03\u4f18\u9009\u9879\u3002"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "input-type",
|
||||
"label": {
|
||||
"en_US": "Input Type",
|
||||
"zh_Hans": "\u8f93\u5165\u7c7b\u578b"
|
||||
},
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "chat",
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "output-type",
|
||||
"label": {
|
||||
"en_US": "Output Type",
|
||||
"zh_Hans": "\u8f93\u51fa\u7c7b\u578b"
|
||||
},
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "chat",
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tweaks",
|
||||
"label": {
|
||||
"en_US": "Tweaks",
|
||||
"zh_Hans": "\u8c03\u6574\u53c2\u6570"
|
||||
},
|
||||
"type": "json",
|
||||
"required": false,
|
||||
"default": "{}",
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "langbot-assets-enabled",
|
||||
"label": {
|
||||
"en_US": "Enable LangBot Assets",
|
||||
"zh_Hans": "\u542f\u7528 LangBot \u8d44\u4ea7"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "langbot-assets-gateway-host",
|
||||
"label": {
|
||||
"en_US": "LangBot Asset Gateway Host",
|
||||
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u76d1\u542c\u5730\u5740"
|
||||
},
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "0.0.0.0",
|
||||
"show_if": {
|
||||
"field": "langbot-assets-enabled",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "langbot-assets-gateway-port",
|
||||
"label": {
|
||||
"en_US": "LangBot Asset Gateway Port",
|
||||
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u7aef\u53e3"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 8765,
|
||||
"show_if": {
|
||||
"field": "langbot-assets-enabled",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "langbot-assets-gateway-request-timeout",
|
||||
"label": {
|
||||
"en_US": "LangBot Asset Gateway Request Timeout",
|
||||
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u8bf7\u6c42\u8d85\u65f6"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 60,
|
||||
"show_if": {
|
||||
"field": "langbot-assets-enabled",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "langbot-assets-token-ttl",
|
||||
"label": {
|
||||
"en_US": "LangBot Asset Token TTL",
|
||||
"zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u6709\u6548\u671f"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 3600,
|
||||
"show_if": {
|
||||
"field": "langbot-assets-enabled",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "langbot-assets-input-name",
|
||||
"label": {
|
||||
"en_US": "LangBot Asset Token Input Name",
|
||||
"zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u8f93\u5165\u7ec4\u4ef6\u540d"
|
||||
},
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "langbot_asset_run_token",
|
||||
"show_if": {
|
||||
"field": "langbot-assets-enabled",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
[
|
||||
{
|
||||
"name": "api-base",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"default": "http://127.0.0.1:2026"
|
||||
},
|
||||
{
|
||||
"name": "api-key",
|
||||
"type": "secret",
|
||||
"required": false,
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "auth-header",
|
||||
"type": "secret",
|
||||
"required": false,
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "assistant-id",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"default": "lead_agent"
|
||||
},
|
||||
{
|
||||
"name": "advanced-settings",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "model-name",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "",
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "thinking-enabled",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "plan-mode",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "subagent-enabled",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "max-concurrent-subagents",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 3,
|
||||
"show_if": {
|
||||
"field": "subagent-enabled",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "timeout",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 300,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "recursion-limit",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 1000,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,427 @@
|
||||
{
|
||||
"usages": [
|
||||
"agent"
|
||||
],
|
||||
"config": [
|
||||
{
|
||||
"name": "model",
|
||||
"description": {
|
||||
"en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.",
|
||||
"zh_Hans": "主模型/备用模型 UUID 与各模型推理等级。由 Host 对每次请求校验并应用,属于流水线独立配置。"
|
||||
},
|
||||
"label": {
|
||||
"en_US": "Model",
|
||||
"zh_Hans": "模型"
|
||||
},
|
||||
"type": "model-fallback-selector",
|
||||
"required": true,
|
||||
"default": {
|
||||
"primary": "",
|
||||
"fallbacks": [],
|
||||
"reasoning": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"label": {
|
||||
"en_US": "Prompt",
|
||||
"zh_Hans": "提示词"
|
||||
},
|
||||
"type": "prompt-editor",
|
||||
"required": true,
|
||||
"default": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "knowledge-bases",
|
||||
"label": {
|
||||
"en_US": "Knowledge Bases",
|
||||
"zh_Hans": "知识库"
|
||||
},
|
||||
"type": "knowledge-base-multi-selector",
|
||||
"required": false,
|
||||
"default": []
|
||||
},
|
||||
{
|
||||
"name": "box-enabled",
|
||||
"label": {
|
||||
"en_US": "Sandbox",
|
||||
"zh_Hans": "沙箱"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Use Box for code execution and file processing.",
|
||||
"zh_Hans": "使用 Box 执行代码和处理文件。"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "box-session-id-template",
|
||||
"label": {
|
||||
"en_US": "Sandbox reuse",
|
||||
"zh_Hans": "沙箱复用范围"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Choose how sandbox files and environments are shared. Custom templates substitute {variable_name}, for example {launcher_type}_{launcher_id}_{sender_id}. Equal results reuse the same sandbox; {global} shares one sandbox throughout the workspace. Available variables: {launcher_type}, {launcher_id}, {sender_id}, {conversation_id}, {bot_id}, {query_id}, {run_id}, {event_id}, {event_type}, and public request variables. Only named variables are supported, not expressions or attribute access; unavailable variables cause an error.",
|
||||
"zh_Hans": "选择沙箱文件和环境的共享方式。自定义模板使用 {变量名} 插值,如 {launcher_type}_{launcher_id}_{sender_id};插值结果相同就复用同一个沙箱,{global} 表示在工作区内全局共享。可用变量:{launcher_type}(会话类型)、{launcher_id}(会话 ID)、{sender_id}(用户 ID)、{conversation_id}(对话上下文 ID)、{bot_id}(机器人 ID)、{query_id}(请求 ID)、{run_id}(运行 ID)、{event_id}(事件 ID)、{event_type}(事件类型),也可引用公开请求变量。仅支持变量名,不支持运算或属性访问;变量不可用时会报错。"
|
||||
},
|
||||
"type": "select",
|
||||
"allow_custom": true,
|
||||
"required": true,
|
||||
"default": "{launcher_type}_{launcher_id}",
|
||||
"options": [
|
||||
{
|
||||
"name": "{global}",
|
||||
"label": {
|
||||
"en_US": "Global (shared by all)",
|
||||
"zh_Hans": "全局(所有人共享)",
|
||||
"zh_Hant": "全域(所有人共用)",
|
||||
"ja_JP": "グローバル(全員共有)",
|
||||
"vi_VN": "Toàn cục (chia sẻ cho tất cả)",
|
||||
"th_TH": "ทั่วไป (แชร์ทั้งหมด)",
|
||||
"es_ES": "Global (compartido por todos)",
|
||||
"ru_RU": "Глобальный (общий для всех)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "{launcher_type}_{launcher_id}",
|
||||
"label": {
|
||||
"en_US": "Per chat (Recommended)",
|
||||
"zh_Hans": "每个会话(推荐)",
|
||||
"zh_Hant": "每個會話(推薦)",
|
||||
"ja_JP": "チャットごと(推奨)",
|
||||
"vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)",
|
||||
"th_TH": "ต่อแชท (แนะนำ)",
|
||||
"es_ES": "Por chat (Recomendado)",
|
||||
"ru_RU": "По чату (Рекомендуется)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "{launcher_type}_{launcher_id}_{sender_id}",
|
||||
"label": {
|
||||
"en_US": "Per user in chat",
|
||||
"zh_Hans": "会话中每个用户",
|
||||
"zh_Hant": "會話中每個用戶",
|
||||
"ja_JP": "チャット内のユーザーごと",
|
||||
"vi_VN": "Mỗi người dùng trong cuộc trò chuyện",
|
||||
"th_TH": "ต่อผู้ใช้ในแชท",
|
||||
"es_ES": "Por usuario en chat",
|
||||
"ru_RU": "По пользователю в чате"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "{launcher_type}_{launcher_id}_{conversation_id}",
|
||||
"label": {
|
||||
"en_US": "Per conversation context",
|
||||
"zh_Hans": "每个对话上下文",
|
||||
"zh_Hant": "每個對話上下文",
|
||||
"ja_JP": "会話コンテキストごと",
|
||||
"vi_VN": "Mỗi ngữ cảnh hội thoại",
|
||||
"th_TH": "ต่อบริบทการสนทนา",
|
||||
"es_ES": "Por contexto de conversación",
|
||||
"ru_RU": "По контексту разговора"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "{query_id}",
|
||||
"label": {
|
||||
"en_US": "Per message (isolated)",
|
||||
"zh_Hans": "每条消息(完全隔离)",
|
||||
"zh_Hant": "每條訊息(完全隔離)",
|
||||
"ja_JP": "メッセージごと(隔離)",
|
||||
"vi_VN": "Mỗi tin nhắn (cách ly)",
|
||||
"th_TH": "ต่อข้อความ (แยกส่วน)",
|
||||
"es_ES": "Por mensaje (aislado)",
|
||||
"ru_RU": "По сообщению (изолированно)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"show_if": {
|
||||
"field": "box-enabled",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "advanced-settings",
|
||||
"label": {
|
||||
"en_US": "Advanced Settings",
|
||||
"zh_Hans": "高级设置"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Show tuning controls for retrieval, tools, timeouts, and context management.",
|
||||
"zh_Hans": "显示检索、工具、超时和上下文管理的调优选项。"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "date-grounding",
|
||||
"label": {
|
||||
"en_US": "Current Date Grounding",
|
||||
"zh_Hans": "当前日期锚定"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Add the current UTC date and a reminder to verify time-sensitive facts with available search tools.",
|
||||
"zh_Hans": "注入当前 UTC 日期,并提醒使用可用搜索工具核实有时效性的信息。"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": true,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "timeout",
|
||||
"label": {
|
||||
"en_US": "Timeout",
|
||||
"zh_Hans": "执行超时"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 300,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "remove-think",
|
||||
"label": {
|
||||
"en_US": "Remove Thinking Output",
|
||||
"zh_Hans": "移除思考内容"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "retrieval-top-k",
|
||||
"label": {
|
||||
"en_US": "Retrieval Top K",
|
||||
"zh_Hans": "知识库检索数量"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 5,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rerank-model",
|
||||
"label": {
|
||||
"en_US": "Rerank Model",
|
||||
"zh_Hans": "重排序模型"
|
||||
},
|
||||
"type": "rerank-model-selector",
|
||||
"required": false,
|
||||
"default": "",
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rerank-top-k",
|
||||
"label": {
|
||||
"en_US": "Rerank Top K",
|
||||
"zh_Hans": "重排序保留数量"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 5,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "max-tool-iterations",
|
||||
"label": {
|
||||
"en_US": "Max Tool Iterations",
|
||||
"zh_Hans": "最大工具调用轮数"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 100,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tool-execution-mode",
|
||||
"description": {
|
||||
"en_US": "Parallel is faster for independent tools. Use serial for dependent or side-effecting actions; result order does not guarantee execution order.",
|
||||
"zh_Hans": "并行适合独立工具;有依赖或副作用的操作请选择串行。结果排列不保证执行顺序。"
|
||||
},
|
||||
"label": {
|
||||
"en_US": "Tool Execution Mode",
|
||||
"zh_Hans": "工具执行模式"
|
||||
},
|
||||
"type": "select",
|
||||
"required": false,
|
||||
"default": "parallel",
|
||||
"options": [
|
||||
{
|
||||
"name": "parallel",
|
||||
"label": {
|
||||
"en_US": "Parallel",
|
||||
"zh_Hans": "并行"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "serial",
|
||||
"label": {
|
||||
"en_US": "Serial",
|
||||
"zh_Hans": "串行"
|
||||
}
|
||||
}
|
||||
],
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "max-tool-result-chars",
|
||||
"label": {
|
||||
"en_US": "Max Tool Result Characters",
|
||||
"zh_Hans": "最大工具结果字符数"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 20000,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "context-history-fetch-limit",
|
||||
"label": {
|
||||
"en_US": "History Fetch Limit",
|
||||
"zh_Hans": "历史消息拉取数量"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 50,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "context-window-tokens",
|
||||
"label": {
|
||||
"en_US": "Context Window Tokens",
|
||||
"zh_Hans": "上下文窗口 Token 数"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 200000,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "context-reserve-tokens",
|
||||
"label": {
|
||||
"en_US": "Reserved Output Tokens",
|
||||
"zh_Hans": "输出保留 Token 数"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 16384,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "context-keep-recent-tokens",
|
||||
"label": {
|
||||
"en_US": "Recent Context Tokens",
|
||||
"zh_Hans": "最近上下文 Token 数"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 20000,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "context-summary-tokens",
|
||||
"label": {
|
||||
"en_US": "Summary Tokens",
|
||||
"zh_Hans": "摘要 Token 数"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 8000,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"capabilities": {
|
||||
"streaming": true,
|
||||
"tool_calling": true,
|
||||
"knowledge_retrieval": true,
|
||||
"multimodal_input": true,
|
||||
"skill_authoring": true,
|
||||
"interrupt": true,
|
||||
"steering": true
|
||||
},
|
||||
"permissions": {
|
||||
"models": [
|
||||
"count_tokens",
|
||||
"invoke",
|
||||
"stream",
|
||||
"rerank"
|
||||
],
|
||||
"tools": [
|
||||
"detail",
|
||||
"call"
|
||||
],
|
||||
"knowledge_bases": [
|
||||
"list",
|
||||
"retrieve"
|
||||
],
|
||||
"history": [
|
||||
"page"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
[
|
||||
{
|
||||
"name": "base-url",
|
||||
"label": {
|
||||
"en_US": "Base URL",
|
||||
"zh_Hans": "\u57fa\u7840 URL"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "The WeKnora API base URL, including /api/v1.",
|
||||
"zh_Hans": "WeKnora API \u57fa\u7840 URL\uff0c\u5305\u542b /api/v1\u3002"
|
||||
},
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"default": "http://localhost:8080/api/v1"
|
||||
},
|
||||
{
|
||||
"name": "api-key",
|
||||
"label": {
|
||||
"en_US": "API Key",
|
||||
"zh_Hans": "API \u5bc6\u94a5"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "API key generated from WeKnora Settings -> API Keys.",
|
||||
"zh_Hans": "\u4ece WeKnora \u8bbe\u7f6e -> API Keys \u751f\u6210\u7684 API \u5bc6\u94a5\u3002"
|
||||
},
|
||||
"type": "secret",
|
||||
"required": true,
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "app-type",
|
||||
"label": {
|
||||
"en_US": "App Type",
|
||||
"zh_Hans": "\u5e94\u7528\u7c7b\u578b"
|
||||
},
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"default": "agent",
|
||||
"options": [
|
||||
{
|
||||
"name": "agent",
|
||||
"label": {
|
||||
"en_US": "Agent (Smart Reasoning)",
|
||||
"zh_Hans": "Agent\uff08\u667a\u80fd\u63a8\u7406\uff09"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chat",
|
||||
"label": {
|
||||
"en_US": "Chat (Knowledge Base RAG)",
|
||||
"zh_Hans": "\u804a\u5929\uff08\u77e5\u8bc6\u5e93 RAG\uff09"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "agent-id",
|
||||
"label": {
|
||||
"en_US": "Agent ID",
|
||||
"zh_Hans": "\u667a\u80fd\u4f53 ID"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "When omitted, chat uses builtin-quick-answer and agent uses builtin-smart-reasoning. An explicit empty value lets the server choose. Saved IDs apply in both modes.",
|
||||
"zh_Hans": "\u672a\u8bbe\u7f6e\u65f6\uff0c\u804a\u5929\u4f7f\u7528 builtin-quick-answer\uff0c\u667a\u80fd\u4f53\u4f7f\u7528 builtin-smart-reasoning\u3002\u663e\u5f0f\u7559\u7a7a\u5219\u7531\u670d\u52a1\u7aef\u9009\u62e9\u3002\u5df2\u4fdd\u5b58\u7684 ID \u5728\u4e24\u79cd\u6a21\u5f0f\u4e0b\u5747\u751f\u6548\u3002"
|
||||
},
|
||||
"type": "string",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "knowledge-base-ids",
|
||||
"label": {
|
||||
"en_US": "Knowledge Base IDs",
|
||||
"zh_Hans": "\u77e5\u8bc6\u5e93 ID \u5217\u8868"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Remote WeKnora knowledge base IDs used in both modes, not LangBot knowledge base UUIDs. Values and order are preserved.",
|
||||
"zh_Hans": "\u4e24\u79cd\u6a21\u5f0f\u5747\u53ef\u4f7f\u7528\u7684\u8fdc\u7aef WeKnora \u77e5\u8bc6\u5e93 ID\uff0c\u4e0d\u662f LangBot \u77e5\u8bc6\u5e93 UUID\u3002\u4fdd\u7559\u539f\u503c\u548c\u987a\u5e8f\u3002"
|
||||
},
|
||||
"type": "array[string]",
|
||||
"required": false,
|
||||
"default": []
|
||||
},
|
||||
{
|
||||
"name": "web-search-enabled",
|
||||
"label": {
|
||||
"en_US": "Enable Web Search",
|
||||
"zh_Hans": "\u542f\u7528\u7f51\u7edc\u641c\u7d22"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Whether to enable web search in agent mode.",
|
||||
"zh_Hans": "\u662f\u5426\u5728 Agent \u6a21\u5f0f\u4e0b\u542f\u7528\u7f51\u7edc\u641c\u7d22\u3002"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false,
|
||||
"show_if": {
|
||||
"field": "app-type",
|
||||
"operator": "eq",
|
||||
"value": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "advanced-settings",
|
||||
"label": {
|
||||
"en_US": "Advanced Settings",
|
||||
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Show timeout and fallback prompt controls.",
|
||||
"zh_Hans": "\u663e\u793a\u8d85\u65f6\u548c\u56de\u9000\u63d0\u793a\u8bcd\u8c03\u4f18\u9009\u9879\u3002"
|
||||
},
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "timeout",
|
||||
"label": {
|
||||
"en_US": "Timeout",
|
||||
"zh_Hans": "\u8d85\u65f6\u65f6\u95f4"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Request timeout in seconds.",
|
||||
"zh_Hans": "\u8bf7\u6c42\u8d85\u65f6\u65f6\u95f4\uff08\u79d2\uff09\u3002"
|
||||
},
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 120,
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "base-prompt",
|
||||
"label": {
|
||||
"en_US": "Base Prompt",
|
||||
"zh_Hans": "\u57fa\u7840\u63d0\u793a\u8bcd"
|
||||
},
|
||||
"description": {
|
||||
"en_US": "Default prompt when the user message is empty.",
|
||||
"zh_Hans": "\u7528\u6237\u6d88\u606f\u4e3a\u7a7a\u65f6\u4f7f\u7528\u7684\u9ed8\u8ba4\u63d0\u793a\u8bcd\u3002"
|
||||
},
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\u8bf7\u56de\u7b54\u7528\u6237\u7684\u95ee\u9898\u3002",
|
||||
"show_if": {
|
||||
"field": "advanced-settings",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -905,3 +905,44 @@ async def test_processor_trace_rejects_other_workspace_or_instance(monkeypatch,
|
||||
with pytest.raises(ValueError, match='Processor run not found'):
|
||||
await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run')
|
||||
store.page_run_events.assert_not_called()
|
||||
|
||||
|
||||
async def test_agent_runs_use_workspace_and_stable_agent_identity(monkeypatch):
|
||||
service = AgentService(_make_app())
|
||||
service.get_agent = AsyncMock(return_value={'kind': 'agent'})
|
||||
service.ap.persistence_mgr.get_db_engine = Mock()
|
||||
store = SimpleNamespace(list_runs=AsyncMock(return_value=([], None, False, 0)))
|
||||
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store))
|
||||
assert (await service.get_processor_runs(WORKSPACE_UUID, 'one', before_id=12))['total'] == 0
|
||||
store.list_runs.assert_awaited_once_with(workspace_id=WORKSPACE_UUID, agent_id='one', before_id=12)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'binding,agent_id,workspace,allowed',
|
||||
[
|
||||
('agent_one_plugin:a/old/default', None, WORKSPACE_UUID, True),
|
||||
('debug:one:plugin:a/new/default', None, WORKSPACE_UUID, True),
|
||||
('any-new-binding', 'one', WORKSPACE_UUID, True),
|
||||
('agent_two_plugin:a/old/default', None, WORKSPACE_UUID, False),
|
||||
('debug:two:plugin:a/new/default', None, WORKSPACE_UUID, False),
|
||||
('agent_one_plugin:a/old/default', 'two', WORKSPACE_UUID, False),
|
||||
('agent_one_plugin:a/old/default', None, 'other-workspace', False),
|
||||
('event_processor:one', None, WORKSPACE_UUID, False),
|
||||
],
|
||||
)
|
||||
async def test_agent_trace_authorizes_history_across_runner_changes(monkeypatch, binding, agent_id, workspace, allowed):
|
||||
service = AgentService(_make_app())
|
||||
service.get_agent = AsyncMock(return_value={'kind': 'agent'})
|
||||
service.ap.persistence_mgr.get_db_engine = Mock()
|
||||
store = SimpleNamespace(
|
||||
get_run=AsyncMock(return_value={'workspace_id': workspace, 'binding_id': binding, 'agent_id': agent_id}),
|
||||
page_run_events=AsyncMock(return_value=([], None, None, False)),
|
||||
)
|
||||
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store))
|
||||
if allowed:
|
||||
await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run', after_sequence=100)
|
||||
store.page_run_events.assert_awaited_once_with(run_id='run', after_sequence=100, limit=100)
|
||||
else:
|
||||
with pytest.raises(ValueError, match='Processor run not found'):
|
||||
await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run')
|
||||
store.page_run_events.assert_not_called()
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import sqlalchemy
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
@@ -456,6 +457,87 @@ class TestBotServiceCreateBot:
|
||||
assert 'use_pipeline_name' not in insert_values
|
||||
assert bot_uuid is not None # Verify UUID was returned
|
||||
|
||||
async def test_failed_apply_keeps_bot_saved_visible_and_retryable(self, tmp_path):
|
||||
"""A saved UUID remains editable after create/update runtime failures."""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.service.bot_errors import BotApplyError
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "bots.db"}')
|
||||
runtime_bot = SimpleNamespace(enable=True, run=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'system': {'limitation': {'max_bots': -1}}}),
|
||||
platform_mgr=SimpleNamespace(
|
||||
load_bot=AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError('Invalid token: original-secret'),
|
||||
RuntimeError('Invalid token: corrected-secret'),
|
||||
runtime_bot,
|
||||
]
|
||||
),
|
||||
remove_bot=AsyncMock(),
|
||||
),
|
||||
sess_mgr=SimpleNamespace(session_list=[]),
|
||||
)
|
||||
ap.persistence_mgr = PersistenceManager(ap)
|
||||
ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
service = BotService(ap)
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sqlalchemy.text('PRAGMA foreign_keys=ON'))
|
||||
await connection.run_sync(User.__table__.create)
|
||||
await connection.run_sync(Workspace.__table__.create)
|
||||
await connection.run_sync(Bot.__table__.create)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace).values(
|
||||
uuid=WORKSPACE_UUID, instance_uuid='instance-a', name='Test', slug='test'
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(BotApplyError) as create_error:
|
||||
await service.create_bot(
|
||||
WORKSPACE_UUID,
|
||||
{
|
||||
'name': 'Saved bot',
|
||||
'description': 'Editable after an adapter failure',
|
||||
'adapter': 'telegram',
|
||||
'adapter_config': {'token': 'original-secret'},
|
||||
'enable': True,
|
||||
},
|
||||
)
|
||||
|
||||
bot_uuid = create_error.value.bot_uuid
|
||||
assert str(uuid.UUID(bot_uuid)) == bot_uuid
|
||||
assert 'original-secret' not in str(create_error.value)
|
||||
assert 'Invalid token' in str(create_error.value)
|
||||
saved = await service.get_bot(WORKSPACE_UUID, bot_uuid, include_secret=True)
|
||||
assert saved['uuid'] == bot_uuid
|
||||
assert saved['adapter_config'] == {'token': 'original-secret'}
|
||||
assert await service.get_bot('workspace-b', bot_uuid) is None
|
||||
assert [bot['uuid'] for bot in await service.get_bots(WORKSPACE_UUID)] == [bot_uuid]
|
||||
|
||||
with pytest.raises(BotApplyError) as update_error:
|
||||
await service.update_bot(WORKSPACE_UUID, bot_uuid, {'adapter_config': {'token': 'corrected-secret'}})
|
||||
assert update_error.value.bot_uuid == bot_uuid
|
||||
assert 'corrected-secret' not in str(update_error.value)
|
||||
saved = await service.get_bot(WORKSPACE_UUID, bot_uuid, include_secret=True)
|
||||
assert saved['adapter_config'] == {'token': 'corrected-secret'}
|
||||
|
||||
await service.update_bot(WORKSPACE_UUID, bot_uuid, {'adapter_config': {'token': 'working-token'}})
|
||||
saved = await service.get_bot(WORKSPACE_UUID, bot_uuid, include_secret=True)
|
||||
assert saved['adapter_config'] == {'token': 'working-token'}
|
||||
assert [bot['uuid'] for bot in await service.get_bots(WORKSPACE_UUID)] == [bot_uuid]
|
||||
async with engine.connect() as connection:
|
||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Bot)) == 1
|
||||
assert ap.platform_mgr.load_bot.await_count == 3
|
||||
assert {call.args[1]['uuid'] for call in ap.platform_mgr.load_bot.await_args_list} == {bot_uuid}
|
||||
runtime_bot.run.assert_awaited_once()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestBotServiceUpdateBot:
|
||||
"""Tests for update_bot method."""
|
||||
|
||||
@@ -1009,6 +1009,37 @@ class TestMCPServiceTestMCPServer:
|
||||
# Verify - returns task ID
|
||||
assert task_id == 123
|
||||
|
||||
@pytest.mark.parametrize('refresh_first', [False, True])
|
||||
async def test_persisted_test_preserves_failure_details(self, refresh_first):
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus
|
||||
|
||||
runtime_info = {'status': 'error', 'error_message': 'HTTP 403: access denied'}
|
||||
session = SimpleNamespace(
|
||||
status=MCPSessionStatus.CONNECTED if refresh_first else MCPSessionStatus.ERROR,
|
||||
session=object(),
|
||||
refresh=AsyncMock(side_effect=RuntimeError('refresh failed')),
|
||||
start=AsyncMock(side_effect=RuntimeError('Connection failed, please check URL')),
|
||||
get_runtime_info_dict=Mock(return_value=runtime_info),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def create_user_task(coroutine, **kwargs):
|
||||
captured.update(coroutine=coroutine, context=kwargs['context'])
|
||||
return SimpleNamespace(id=123)
|
||||
|
||||
ap = SimpleNamespace(
|
||||
tool_mgr=SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=session))),
|
||||
task_mgr=SimpleNamespace(create_user_task=Mock(side_effect=create_user_task)),
|
||||
)
|
||||
service = _service(ap)
|
||||
service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'}))
|
||||
await service.test_mcp_server(_CONTEXT, 'existing-server', {})
|
||||
with pytest.raises(RuntimeError, match='Connection failed'):
|
||||
await captured['coroutine']
|
||||
assert captured['context'].metadata['runtime_info'] == runtime_info
|
||||
session.start.assert_awaited_once()
|
||||
assert session.refresh.await_count == int(refresh_first)
|
||||
|
||||
async def test_test_mcp_server_not_found_raises(self):
|
||||
"""Raises ValueError when server not found."""
|
||||
# Setup
|
||||
@@ -1052,6 +1083,45 @@ class TestMCPServiceTestMCPServer:
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
||||
assert task_id == 456
|
||||
|
||||
async def test_transient_test_preserves_runtime_info_after_connection_failure(self):
|
||||
runtime_info = {
|
||||
'status': 'error',
|
||||
'error_phase': 'oauth_required',
|
||||
'retry_count': 1,
|
||||
}
|
||||
mock_session = SimpleNamespace(
|
||||
server_name='oauth-server',
|
||||
start=AsyncMock(side_effect=RuntimeError('connection failed')),
|
||||
get_runtime_info_dict=Mock(return_value=runtime_info),
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
tool_mgr=SimpleNamespace(
|
||||
mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock(return_value=mock_session))
|
||||
)
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
def create_user_task(coroutine, **kwargs):
|
||||
captured['coroutine'] = coroutine
|
||||
captured['context'] = kwargs['context']
|
||||
return SimpleNamespace(id=457)
|
||||
|
||||
ap.task_mgr = SimpleNamespace(create_user_task=Mock(side_effect=create_user_task))
|
||||
service = _service(ap)
|
||||
|
||||
task_id = await service.test_mcp_server(
|
||||
_CONTEXT,
|
||||
'_',
|
||||
{'name': 'OAuth server', 'mode': 'remote', 'enable': True, 'extra_args': {}},
|
||||
)
|
||||
|
||||
assert task_id == 457
|
||||
with pytest.raises(RuntimeError, match='connection failed'):
|
||||
await captured['coroutine']
|
||||
assert captured['context'].metadata['runtime_info'] == runtime_info
|
||||
mock_session.shutdown.assert_awaited_once_with()
|
||||
|
||||
async def test_rejected_transient_test_session_is_shut_down(self):
|
||||
ap = SimpleNamespace()
|
||||
mock_session = MagicMock()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Identifier normalization must not rely on SQLite's permissive codecs."""
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.service import monitoring
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('value', 'expected'),
|
||||
[(None, None), ('', ''), ('00123', '00123'), (' 用户 ', ' 用户 '), (123, '123'), (-123, '-123'), (0, '0')],
|
||||
)
|
||||
def test_normalize_user_id_preserves_opaque_strings(value, expected):
|
||||
assert monitoring._normalize_user_id(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [True, False, 1.5, b'123', ['123'], {'id': 123}])
|
||||
def test_normalize_user_id_rejects_unsupported_types(value):
|
||||
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
|
||||
monitoring._normalize_user_id(value)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Bot-scoped session regressions exercised against real SQL databases."""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence import monitoring as models
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
|
||||
|
||||
from tests.integration.persistence.test_monitoring_postgres import cloud_database # noqa: F401
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope='module')
|
||||
async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811
|
||||
import asyncio
|
||||
import importlib
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context, _read
|
||||
|
||||
ap, admin = cloud_database
|
||||
service = ap.monitoring_service
|
||||
ctx = _context(WORKSPACE_A)
|
||||
await service.record_session_start(ctx, session_id='person_42', **resource('a'))
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_message(ctx, session_id='person_42', message_content=bot, **resource(bot))
|
||||
async with admin.begin() as conn:
|
||||
|
||||
def migrate(connection):
|
||||
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.downgrade()
|
||||
migration.upgrade()
|
||||
rls = connection.execute(
|
||||
sa.text("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='monitoring_sessions'")
|
||||
).one()
|
||||
assert tuple(rls) == (True, True)
|
||||
assert (
|
||||
connection.execute(
|
||||
sa.text("SELECT count(*) FROM pg_policies WHERE tablename='monitoring_sessions'")
|
||||
).scalar_one()
|
||||
== 1
|
||||
)
|
||||
|
||||
await conn.run_sync(migrate)
|
||||
rows, total = await _read(service, 'get_sessions', ctx)
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
||||
await asyncio.gather(*[service.record_session_start(ctx, session_id='race', **resource('a')) for _ in range(10)])
|
||||
result = await _read(service, 'get_session_analysis', ctx, 'race', bot_id='a')
|
||||
assert result['session']['message_count'] == 10
|
||||
assert not (await _read(service, 'get_session_analysis', ctx, 'person_42'))['found']
|
||||
assert (await _read(service, 'get_session_analysis', ctx, 'person_42', bot_id='b'))['message_stats']['total'] == 1
|
||||
|
||||
|
||||
async def test_migration_reconstructs_collisions_and_preserves_indexes(service):
|
||||
import importlib
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
engine = service.ap.persistence_mgr.get_db_engine()
|
||||
async with engine.begin() as conn:
|
||||
|
||||
def upgrade(connection):
|
||||
table = models.MonitoringSession.__table__
|
||||
table.drop(connection)
|
||||
metadata = sa.MetaData()
|
||||
legacy = table.to_metadata(metadata)
|
||||
legacy.primary_key._columns.remove(legacy.c.bot_id)
|
||||
legacy.c.bot_id.primary_key = False
|
||||
# Resolve the unchanged Workspace FK in copied metadata.
|
||||
Base.metadata.tables['workspaces'].to_metadata(metadata)
|
||||
legacy.create(connection)
|
||||
now = dt.datetime(2026, 1, 1)
|
||||
connection.execute(
|
||||
sa.insert(legacy).values(
|
||||
workspace_uuid='workspace',
|
||||
session_id='person_42',
|
||||
**resource('a'),
|
||||
message_count=99,
|
||||
start_time=now,
|
||||
last_activity=now,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
for bot in ['a', 'b']:
|
||||
connection.execute(
|
||||
sa.insert(models.MonitoringMessage).values(
|
||||
id=bot,
|
||||
workspace_uuid='workspace',
|
||||
timestamp=now,
|
||||
**resource(bot),
|
||||
session_id='person_42',
|
||||
message_content=bot,
|
||||
role='user',
|
||||
status='success',
|
||||
level='info',
|
||||
)
|
||||
)
|
||||
indexes = {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
||||
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
migration.upgrade() # Fresh/already-upgraded schema is safe.
|
||||
assert sa.inspect(connection).get_pk_constraint('monitoring_sessions')['constrained_columns'] == [
|
||||
'workspace_uuid',
|
||||
'bot_id',
|
||||
'session_id',
|
||||
]
|
||||
assert indexes <= {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
||||
|
||||
await conn.run_sync(upgrade)
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
||||
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
||||
|
||||
|
||||
def context(bot=None):
|
||||
return ExecutionContext(instance_uuid='test', workspace_uuid='workspace', placement_generation=1, bot_uuid=bot)
|
||||
|
||||
|
||||
def resource(bot):
|
||||
return dict(bot_id=bot, bot_name=bot, pipeline_id=bot, pipeline_name=bot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service():
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
class Persistence:
|
||||
serialize_model = PersistenceManager.serialize_model
|
||||
|
||||
def get_db_engine(self):
|
||||
return engine
|
||||
|
||||
async def execute_async(self, stmt):
|
||||
async with engine.begin() as conn:
|
||||
return await conn.execute(stmt)
|
||||
|
||||
ap = SimpleNamespace(persistence_mgr=Persistence(), logger=logging.getLogger(__name__))
|
||||
ap.monitoring_service = MonitoringService(ap)
|
||||
yield ap.monitoring_service
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_helper_first_message_count_and_two_bot_isolation(service):
|
||||
for bot in ['a', 'b', 'a']:
|
||||
query = SimpleNamespace(
|
||||
_execution_context=context(bot),
|
||||
launcher_type='person',
|
||||
launcher_id=42,
|
||||
sender_id=42,
|
||||
message_chain=SimpleNamespace(model_dump=lambda: []),
|
||||
)
|
||||
assert await MonitoringHelper.record_query_start(service.ap, query, **resource(bot))
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 2, 'b': 1}
|
||||
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
||||
assert {r['session_id'] for r in rows} == {'person_42'}
|
||||
|
||||
|
||||
async def test_analysis_fails_closed_and_scopes_statistics(service):
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
||||
await service.record_message(context(bot), session_id='person_42', message_content=bot, **resource(bot))
|
||||
assert (await service.get_session_analysis(context(), 'person_42'))['found'] is False
|
||||
result = await service.get_session_analysis(context(), 'person_42', bot_id='b')
|
||||
assert result['message_stats']['total'] == 1
|
||||
assert result['session']['bot_id'] == 'b'
|
||||
|
||||
|
||||
async def test_activity_requires_bot_and_upsert_counts_racing_first_queries(service):
|
||||
for _ in range(2):
|
||||
await service.record_session_start(context('a'), session_id='person_42', **resource('a'))
|
||||
with pytest.raises(ValueError, match='bot'):
|
||||
await service.update_session_activity(context(), 'person_42')
|
||||
assert await service.update_session_activity(context('a'), 'person_42')
|
||||
assert not await service.update_session_activity(context('b'), 'person_42')
|
||||
rows, _ = await service.get_sessions(context())
|
||||
assert rows[0]['message_count'] == 3
|
||||
|
||||
|
||||
async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service):
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
||||
old = dt.datetime(2000, 1, 1)
|
||||
await service.ap.persistence_mgr.execute_async(sa.update(models.MonitoringSession).values(start_time=old))
|
||||
await service.ap.persistence_mgr.execute_async(
|
||||
sa.update(models.MonitoringSession).where(models.MonitoringSession.bot_id == 'a').values(last_activity=old)
|
||||
)
|
||||
since = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) - dt.timedelta(days=1)
|
||||
rows, total = await service.get_sessions(context(), start_time=since)
|
||||
assert total == 1 and rows[0]['bot_id'] == 'b'
|
||||
assert len(await service.export_sessions(context(), start_time=since)) == 1
|
||||
count = await service._delete_expired_in_batches(
|
||||
context(),
|
||||
models.MonitoringSession,
|
||||
models.MonitoringSession.last_activity,
|
||||
models.MonitoringSession.session_id,
|
||||
since,
|
||||
1,
|
||||
2,
|
||||
)
|
||||
assert count == 1
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 1 and rows[0]['bot_id'] == 'b'
|
||||
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
||||
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
||||
|
||||
|
||||
async def test_session_search_matches_user_id_or_name_within_workspace(service):
|
||||
context_a = _context(WORKSPACE_A)
|
||||
context_b = _context(WORKSPACE_B)
|
||||
fixtures = [
|
||||
(context_a, 'session-id-match', 'customer-42', 'Alice'),
|
||||
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
|
||||
(context_a, 'session-no-match', 'customer-7', 'Bob'),
|
||||
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
|
||||
]
|
||||
for context, session_id, user_id, user_name in fixtures:
|
||||
await service.record_session_start(
|
||||
context,
|
||||
session_id=session_id,
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
|
||||
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
|
||||
|
||||
assert id_total == 1
|
||||
assert [session['session_id'] for session in by_id] == ['session-id-match']
|
||||
assert name_total == 2
|
||||
assert {session['session_id'] for session in by_name} == {
|
||||
'session-id-match',
|
||||
'session-name-match',
|
||||
}
|
||||
|
||||
|
||||
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
message_id = await _record_message(service, context, 'tool context')
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
A = '00000000-0000-0000-0000-00000000000a'
|
||||
B = '00000000-0000-0000-0000-00000000000b'
|
||||
START = datetime.datetime(2026, 1, 1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def traffic_app():
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace),
|
||||
[
|
||||
{'uuid': wid, 'instance_uuid': 'instance', 'name': wid, 'slug': wid, 'source': 'cloud_projection'}
|
||||
for wid in (A, B)
|
||||
],
|
||||
)
|
||||
for wid, bot, count in [(A, 'bot-a', 60), (A, 'bot-b', 7), (B, 'bot-a', 9)]:
|
||||
common = {
|
||||
'workspace_uuid': wid,
|
||||
'timestamp': START,
|
||||
'bot_id': bot,
|
||||
'bot_name': bot,
|
||||
'pipeline_id': 'pipeline',
|
||||
'pipeline_name': 'Pipeline',
|
||||
'session_id': 'person_42',
|
||||
'status': 'success',
|
||||
}
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringMessage),
|
||||
[
|
||||
dict(common, id=f'{wid}-{bot}-{i}', message_content='test fixture', level='info', role='user')
|
||||
for i in range(count)
|
||||
],
|
||||
)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringLLMCall),
|
||||
[
|
||||
dict(
|
||||
common,
|
||||
id=f'{wid}-{bot}-{i}',
|
||||
model_name='fixture-model',
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
total_tokens=2,
|
||||
duration=1,
|
||||
)
|
||||
for i in range(count)
|
||||
],
|
||||
)
|
||||
|
||||
class Persistence:
|
||||
def get_db_engine(self):
|
||||
return engine
|
||||
|
||||
async def execute_async(self, statement):
|
||||
async with engine.connect() as connection:
|
||||
return await connection.execute(statement)
|
||||
|
||||
yield SimpleNamespace(persistence_mgr=Persistence())
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_traffic_counts_all_rows_not_just_latest_page(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
|
||||
result = await get_traffic_series(
|
||||
traffic_app, context, bot_ids=['bot-a'], start_time=START, end_time=START + datetime.timedelta(hours=2)
|
||||
)
|
||||
assert result['bucket'] == 'hour'
|
||||
assert result['truncated'] is False
|
||||
assert sum(point['messages'] for point in result['points']) == 60
|
||||
assert sum(point['llm_calls'] for point in result['points']) == 60
|
||||
assert len(result['points']) == 3
|
||||
assert result['points'][1]['messages'] == result['points'][1]['llm_calls'] == 0
|
||||
assert result['points'][0]['timestamp'] == '2026-01-01T00:00:00Z'
|
||||
|
||||
|
||||
async def test_traffic_workspace_pipeline_and_empty_filters(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=B, placement_generation=1)
|
||||
kwargs = dict(start_time=START, end_time=START + datetime.timedelta(hours=2))
|
||||
result = await get_traffic_series(traffic_app, context, **kwargs)
|
||||
assert sum(point['messages'] for point in result['points']) == 9
|
||||
empty = await get_traffic_series(traffic_app, context, pipeline_ids=['missing'], **kwargs)
|
||||
assert sum(point['messages'] for point in empty['points']) == 0
|
||||
assert sum(point['llm_calls'] for point in empty['points']) == 0
|
||||
|
||||
|
||||
async def test_traffic_bounds_large_ranges_and_marks_truncation(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
|
||||
result = await get_traffic_series(
|
||||
traffic_app, context, start_time=START, end_time=START + datetime.timedelta(days=5000)
|
||||
)
|
||||
assert result['bucket'] == 'day'
|
||||
assert result['truncated'] is True
|
||||
assert len(result['points']) == 1000
|
||||
|
||||
|
||||
async def test_traffic_fails_closed_without_workspace(traffic_app):
|
||||
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
with pytest.raises(WorkspaceRequiredError):
|
||||
await get_traffic_series(traffic_app, None)
|
||||
@@ -0,0 +1,953 @@
|
||||
"""Manual migration boundary tests; all configs are synthetic."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.authz import PermissionDeniedError, permissions_for_role
|
||||
from langbot.pkg.api.http.context import RequestContext, PrincipalContext, PrincipalType, WorkspaceContext
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
||||
from langbot.pkg.entity.persistence.plugin import PluginSetting
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.core.taskmgr import AsyncTaskManager
|
||||
|
||||
MODULE = 'langbot.pkg.api.http.service.pipeline_migration'
|
||||
RID = 'plugin:langbot-team/TestAgent/default'
|
||||
WS = '00000000-0000-0000-0000-000000000001'
|
||||
OTHER = '00000000-0000-0000-0000-000000000002'
|
||||
SOURCE = {'ai': {'runner': 'test', 'secret': 'synthetic-secret'}, 'output': {'keep': True}}
|
||||
|
||||
|
||||
def module():
|
||||
assert importlib.util.find_spec(MODULE) is not None, 'manual migration service is missing'
|
||||
return importlib.import_module(MODULE)
|
||||
|
||||
|
||||
def context(role='developer'):
|
||||
return RequestContext(
|
||||
'instance',
|
||||
1,
|
||||
'request',
|
||||
'user_token',
|
||||
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'),
|
||||
WorkspaceContext(WS, 'membership', role, permissions_for_role(role)),
|
||||
)
|
||||
|
||||
|
||||
def planner(config, extensions_preferences=None):
|
||||
base = dict(
|
||||
state='already_current',
|
||||
legacy_runner=None,
|
||||
target_runner_id=None,
|
||||
target_plugin=None,
|
||||
changed_paths=[],
|
||||
blockers=[],
|
||||
warnings=[],
|
||||
)
|
||||
if isinstance(config.get('ai', {}).get('runner'), str):
|
||||
target = copy.deepcopy(config)
|
||||
target['ai'] = {'runner': {'id': RID}, 'runner_config': {RID: {'api_key': config['ai']['secret']}}}
|
||||
base.update(
|
||||
state='ready',
|
||||
legacy_runner='test',
|
||||
target_runner_id=RID,
|
||||
target_plugin={'author': 'langbot-team', 'name': 'TestAgent', 'version': '1.0'},
|
||||
changed_paths=['ai.runner', 'ai.runner_config'],
|
||||
config=target,
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
class PM(PersistenceManager):
|
||||
def __init__(self, engine):
|
||||
super().__init__(NS())
|
||||
self.db = NS(get_engine=lambda: engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def env(tmp_path, monkeypatch):
|
||||
m = module()
|
||||
monkeypatch.setattr(m, 'plan_legacy_pipeline', planner)
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "migration.db"}')
|
||||
async with engine.begin() as conn:
|
||||
names = {
|
||||
'users',
|
||||
'workspaces',
|
||||
'workspace_memberships',
|
||||
'workspace_execution_states',
|
||||
'legacy_pipelines',
|
||||
'plugin_settings',
|
||||
'pipeline_migration_snapshots',
|
||||
'agent_interaction',
|
||||
}
|
||||
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[Base.metadata.tables[n] for n in names]))
|
||||
await conn.execute(
|
||||
sa.insert(User).values(
|
||||
uuid='account',
|
||||
user='[email protected]',
|
||||
normalized_email='[email protected]',
|
||||
password='synthetic-not-a-password',
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(Workspace),
|
||||
[
|
||||
dict(uuid=ws, instance_uuid='instance', name=ws, slug=ws, source='cloud_projection')
|
||||
for ws in (WS, OTHER)
|
||||
],
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(LegacyPipeline),
|
||||
[
|
||||
dict(
|
||||
uuid=pid,
|
||||
workspace_uuid=ws,
|
||||
name=pid,
|
||||
description='kept',
|
||||
for_version='4.10',
|
||||
stages=[],
|
||||
config=SOURCE,
|
||||
extensions_preferences={'enable_all_plugins': True},
|
||||
)
|
||||
for pid, ws in [('one', WS), ('two', WS), ('foreign', OTHER)]
|
||||
],
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(PluginSetting).values(
|
||||
workspace_uuid=WS,
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='TestAgent',
|
||||
enabled=True,
|
||||
install_info={'version': '1.0'},
|
||||
)
|
||||
)
|
||||
pm = PM(engine)
|
||||
binding = NS(instance_uuid='instance', workspace_uuid=WS, placement_generation=1)
|
||||
access = NS(
|
||||
workspace=NS(uuid=WS),
|
||||
membership=NS(uuid='membership', role='developer', projection_revision=0),
|
||||
execution=binding,
|
||||
)
|
||||
ap = NS(
|
||||
persistence_mgr=pm,
|
||||
event_loop=asyncio.get_running_loop(),
|
||||
instance_config=NS(data={}),
|
||||
workspace_service=NS(get_execution_binding=AsyncMock(return_value=binding)),
|
||||
workspace_collaboration_service=NS(resolve_account_workspace=AsyncMock(return_value=access)),
|
||||
pipeline_mgr=NS(prepare_pipeline=AsyncMock(return_value='candidate'), publish_pipeline=Mock()),
|
||||
sess_mgr=NS(session_list=[]),
|
||||
runner_registry=NS(
|
||||
list_runners=AsyncMock(
|
||||
return_value=[
|
||||
NS(
|
||||
id=RID,
|
||||
usages=['agent'],
|
||||
plugin_version='1.0',
|
||||
config_schema=[{'name': 'api_key', 'type': 'string', 'required': True}],
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
plugin_connector=NS(require_workspace_context=AsyncMock()),
|
||||
)
|
||||
ap.task_mgr = AsyncTaskManager(ap)
|
||||
svc = m.PipelineMigrationService(ap)
|
||||
yield NS(m=m, ap=ap, svc=svc, engine=engine, pm=pm, access=access)
|
||||
await ap.task_mgr.wait_all()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def selection(env, ids=('one',)):
|
||||
preview = await env.svc.preview(context())
|
||||
return {
|
||||
'confirmed': True,
|
||||
'items': [
|
||||
{k: item[k] for k in ('pipeline_uuid', 'preview_token')}
|
||||
for item in preview['items']
|
||||
if item['pipeline_uuid'] in ids
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def execute(env, body=None):
|
||||
result = await env.svc.execute(context(), body or await selection(env))
|
||||
task = env.ap.task_mgr.get_task_by_id(result['task_id'])
|
||||
await task.task
|
||||
return task
|
||||
|
||||
|
||||
async def rows(env):
|
||||
async with env.engine.connect() as conn:
|
||||
configs = dict((await conn.execute(sa.select(LegacyPipeline.uuid, LegacyPipeline.config))).all())
|
||||
backups = (await conn.execute(sa.select(env.m.PipelineMigrationSnapshot))).mappings().all()
|
||||
return configs, backups
|
||||
|
||||
|
||||
def test_strict_confirmation_and_selection():
|
||||
m = module()
|
||||
valid = {'confirmed': True, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'token'}]}
|
||||
assert m.validate_execute_request(valid) == valid['items']
|
||||
for body in [
|
||||
None,
|
||||
{},
|
||||
{**valid, 'confirmed': 'true'},
|
||||
{**valid, 'confirmed': 1},
|
||||
{**valid, 'confirmed': False},
|
||||
{**valid, 'workspace_uuid': WS},
|
||||
{**valid, 'items': []},
|
||||
{**valid, 'items': valid['items'] * 2},
|
||||
{**valid, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'x', 'config': {}}]},
|
||||
{**valid, 'items': [{'pipeline_uuid': str(i), 'preview_token': 'x'} for i in range(51)]},
|
||||
]:
|
||||
with pytest.raises(m.MigrationError):
|
||||
m.validate_execute_request(body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_is_scoped_secret_free_and_read_only(env):
|
||||
writes = []
|
||||
|
||||
def observe(_conn, _cursor, statement, *_args):
|
||||
if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
|
||||
writes.append(statement)
|
||||
|
||||
sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe)
|
||||
result = await env.svc.preview(context('viewer'))
|
||||
assert result['total'] == 2
|
||||
assert {i['pipeline_uuid'] for i in result['items']} == {'one', 'two'}
|
||||
assert all(i['state'] == 'ready' and i['preview_token'] for i in result['items'])
|
||||
assert 'synthetic-secret' not in str(result)
|
||||
assert writes == []
|
||||
env.ap.runner_registry.list_runners.assert_not_awaited()
|
||||
env.ap.plugin_connector.require_workspace_context.assert_not_awaited()
|
||||
env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited()
|
||||
assert not env.ap.task_mgr.tasks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_and_foreign_ids_rejected_before_runtime(env):
|
||||
body = await selection(env)
|
||||
with pytest.raises(PermissionDeniedError):
|
||||
await env.svc.execute(context('viewer'), body)
|
||||
body['items'].append({'pipeline_uuid': 'foreign', 'preview_token': 'x'})
|
||||
with pytest.raises(env.m.MigrationError) as err:
|
||||
await env.svc.execute(context(), body)
|
||||
assert err.value.status_code == 404
|
||||
assert not env.ap.task_mgr.tasks
|
||||
env.ap.runner_registry.list_runners.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_snapshot_atomic_preserves_source_and_task_scope(env):
|
||||
task = await execute(env)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == planner(SOURCE)['config']
|
||||
assert configs['two'] == SOURCE
|
||||
assert len(backups) == 1 and backups[0]['source_snapshot']['config'] == SOURCE
|
||||
assert backups[0]['state'] == 'active'
|
||||
assert (task.instance_uuid, task.workspace_uuid, task.placement_generation) == ('instance', WS, 1)
|
||||
assert task.task_context.metadata == {
|
||||
'kind': 'pipeline_migration',
|
||||
'results': [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}],
|
||||
}
|
||||
assert 'synthetic-secret' not in str(task.to_dict())
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_preview_and_plugin_change_are_rejected(env):
|
||||
body = await selection(env)
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config={'edit': True}))
|
||||
with pytest.raises(env.m.MigrationError) as err:
|
||||
await env.svc.execute(context(), body)
|
||||
assert err.value.status_code == 409
|
||||
body = await selection(env, ('two',))
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.update(PluginSetting).values(enabled=False))
|
||||
with pytest.raises(env.m.MigrationError):
|
||||
await env.svc.execute(context(), body)
|
||||
assert not env.ap.task_mgr.tasks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', ['prepare', 'schema', 'membership', 'generation', 'edit'])
|
||||
async def test_precommit_failures_keep_original_without_backup(env, failure):
|
||||
body = await selection(env)
|
||||
if failure == 'prepare':
|
||||
env.ap.pipeline_mgr.prepare_pipeline.side_effect = RuntimeError('synthetic-secret')
|
||||
elif failure == 'schema':
|
||||
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
|
||||
|
||||
async def prepare(*args, **kwargs):
|
||||
assert env.pm.current_session() is None
|
||||
if failure == 'membership':
|
||||
env.access.membership.role = 'viewer'
|
||||
if failure == 'generation':
|
||||
env.ap.workspace_service.get_execution_binding.side_effect = RuntimeError('synthetic-secret')
|
||||
if failure == 'edit':
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.update(LegacyPipeline)
|
||||
.where(LegacyPipeline.uuid == 'one')
|
||||
.values(extensions_preferences={'enable_all_plugins': False})
|
||||
)
|
||||
return 'candidate'
|
||||
|
||||
if failure not in ('prepare', 'schema'):
|
||||
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
|
||||
task = await execute(env, body)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
assert task.task_context.metadata['results'][0]['state'] in ('blocked', 'failed', 'stale')
|
||||
assert 'synthetic-secret' not in str(task.to_dict())
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activation_pending_explicit_retry_without_second_backup(env):
|
||||
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] != SOURCE and len(backups) == 1
|
||||
preview = await env.svc.preview(context())
|
||||
assert preview['items'][0]['state'] == 'activation_pending'
|
||||
env.ap.pipeline_mgr.publish_pipeline.side_effect = None
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
|
||||
_, backups = await rows(env)
|
||||
assert len(backups) == 1 and backups[0]['state'] == 'active'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_execute_commits_once_and_partial_results(env):
|
||||
body = await selection(env, ('one', 'two'))
|
||||
original_prepare = env.ap.pipeline_mgr.prepare_pipeline
|
||||
|
||||
async def prepare(_ctx, entity):
|
||||
assert env.pm.current_session() is None
|
||||
if entity['uuid'] == 'two':
|
||||
raise RuntimeError('synthetic-secret')
|
||||
await asyncio.sleep(0)
|
||||
return 'candidate'
|
||||
|
||||
original_prepare.side_effect = prepare
|
||||
first, second = await asyncio.gather(env.svc.execute(context(), body), env.svc.execute(context(), body))
|
||||
await env.ap.task_mgr.wait_all()
|
||||
configs, backups = await rows(env)
|
||||
assert len(backups) == 1
|
||||
assert configs['two'] == SOURCE
|
||||
results = [env.ap.task_mgr.get_task_by_id(r['task_id']).task_context.metadata['results'] for r in (first, second)]
|
||||
assert sum(item['state'] == 'migrated' for batch in results for item in batch) == 1
|
||||
assert all(batch[1]['state'] == 'failed' for batch in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env):
|
||||
from langbot.pkg.api.http.service.pipeline import PipelineService
|
||||
|
||||
service = PipelineService(env.ap)
|
||||
env.ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
||||
env.ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
async with env.pm.tenant_scope(WS):
|
||||
with pytest.raises(ValueError, match='manual_migration_required'):
|
||||
await service.update_pipeline(context(), 'one', {'config': planner(SOURCE)['config']})
|
||||
await service.update_pipeline(context(), 'one', {'name': 'renamed'})
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_commit_failure_rolls_back_config_and_backup(env, monkeypatch):
|
||||
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
||||
|
||||
original = AsyncSessionTransaction.commit
|
||||
|
||||
async def fail_write_commit(transaction):
|
||||
# Fail the actual transaction after both statements have run.
|
||||
result = await transaction.session.execute(
|
||||
sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot)
|
||||
)
|
||||
if result.scalar() > 0:
|
||||
raise RuntimeError('synthetic-secret')
|
||||
await original(transaction)
|
||||
|
||||
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_write_commit)
|
||||
task = await execute(env)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'failed'
|
||||
assert 'synthetic-secret' not in str(task.to_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_changes_while_preparing_prevent_commit(env):
|
||||
async def prepare(*args):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.update(PluginSetting).values(runtime_revision=2))
|
||||
return 'candidate'
|
||||
|
||||
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
|
||||
task = await execute(env)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'stale'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_token_cannot_be_reused_by_other_identity_or_generation(env):
|
||||
import dataclasses
|
||||
|
||||
body = await selection(env)
|
||||
ctx = dataclasses.replace(context(), placement_generation=2)
|
||||
env.access.execution.placement_generation = 2
|
||||
with pytest.raises(env.m.MigrationError):
|
||||
await env.svc.execute(ctx, body)
|
||||
assert not env.ap.task_mgr.tasks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_invalidation_is_workspace_scoped(env):
|
||||
good = NS(workspace_uuid=WS, using_conversation=NS(pipeline_uuid='one'))
|
||||
foreign = NS(workspace_uuid=OTHER, using_conversation=NS(pipeline_uuid='one'))
|
||||
env.ap.sess_mgr.session_list = [good, foreign]
|
||||
await execute(env)
|
||||
assert good.using_conversation is None
|
||||
assert foreign.using_conversation is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_schema_changes_during_prepare_prevent_commit(env):
|
||||
async def prepare(*args):
|
||||
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
|
||||
return 'candidate'
|
||||
|
||||
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
|
||||
task = await execute(env)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_planner_deerflow_schema_integration(env, monkeypatch):
|
||||
import json
|
||||
from pathlib import Path
|
||||
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
|
||||
|
||||
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
|
||||
source = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'deerflow-api', 'expire-time': 60},
|
||||
'deerflow-api': {'api-base': 'https://synthetic.invalid', 'api-key': 'synthetic-secret'},
|
||||
},
|
||||
'output': {'misc': {'remove-think': False}},
|
||||
}
|
||||
plan = plan_legacy_pipeline(source, {'enable_all_plugins': True})
|
||||
assert plan['state'] == 'ready'
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config=source))
|
||||
await conn.execute(
|
||||
sa.insert(PluginSetting).values(
|
||||
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='DeerFlowAgent', enabled=True
|
||||
)
|
||||
)
|
||||
schema = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_deerflow_schema.json').read_text())
|
||||
env.ap.runner_registry.list_runners.return_value = [
|
||||
NS(
|
||||
id=plan['target_runner_id'],
|
||||
usages=['agent'],
|
||||
plugin_version=plan['target_plugin']['version'],
|
||||
config_schema=schema,
|
||||
)
|
||||
]
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == plan['config'] and backups[0]['source_snapshot']['config'] == source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_disabled_during_prepare_blocks_commit(env):
|
||||
async def prepare(*args):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.update(User).where(User.uuid == 'account').values(status='disabled'))
|
||||
return 'candidate'
|
||||
|
||||
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
|
||||
task = await execute(env)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_disabled_after_commit_defers_activation(env, monkeypatch):
|
||||
original = env.svc._commit
|
||||
|
||||
async def commit(*args):
|
||||
result = await original(*args)
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.update(PluginSetting).values(enabled=False))
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(env.svc, '_commit', commit)
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_commit_acknowledgement_reports_pending(env, monkeypatch):
|
||||
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
||||
|
||||
original = AsyncSessionTransaction.commit
|
||||
failed = False
|
||||
|
||||
async def fail_ack(transaction):
|
||||
nonlocal failed
|
||||
count = (
|
||||
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
|
||||
).scalar()
|
||||
await original(transaction)
|
||||
if count and not failed:
|
||||
failed = True
|
||||
raise RuntimeError('synthetic-secret')
|
||||
|
||||
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_ack)
|
||||
task = await execute(env)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] != SOURCE and len(backups) == 1
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('durable', [False, True], ids=['before_commit', 'after_durable_commit'])
|
||||
async def test_cancel_commit_reports_durable_outcome_and_stops_batch(env, monkeypatch, durable):
|
||||
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
||||
|
||||
original = AsyncSessionTransaction.commit
|
||||
cancelled = False
|
||||
|
||||
async def cancel_commit(transaction):
|
||||
nonlocal cancelled
|
||||
count = (
|
||||
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
|
||||
).scalar()
|
||||
if count and not cancelled:
|
||||
cancelled = True
|
||||
if durable:
|
||||
await original(transaction)
|
||||
asyncio.current_task().cancel()
|
||||
await asyncio.sleep(0)
|
||||
await original(transaction)
|
||||
|
||||
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_commit)
|
||||
task = await execute(env, await selection(env, ('one', 'two')))
|
||||
configs, backups = await rows(env)
|
||||
assert cancelled
|
||||
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
|
||||
assert len(backups) == int(durable)
|
||||
if durable:
|
||||
assert backups[0]['state'] == 'activation_pending'
|
||||
assert configs['two'] == SOURCE
|
||||
assert task.task_context.metadata['results'] == [
|
||||
{'pipeline_uuid': 'one', 'state': 'activation_pending' if durable else 'failed', 'code': 'operation_cancelled'},
|
||||
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
|
||||
]
|
||||
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('durable', [False, True])
|
||||
@pytest.mark.parametrize('initial_cancel', [False, True])
|
||||
@pytest.mark.parametrize('reconcile_cancel', [False, True])
|
||||
async def test_unavailable_commit_reconciliation_is_conservative(
|
||||
env, monkeypatch, durable, initial_cancel, reconcile_cancel
|
||||
):
|
||||
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
||||
|
||||
original_commit = AsyncSessionTransaction.commit
|
||||
original_execute = env.pm.execute_async
|
||||
interrupted = False
|
||||
reconciliation_attempts = 0
|
||||
|
||||
async def interrupt_commit(transaction):
|
||||
nonlocal interrupted
|
||||
count = (
|
||||
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
|
||||
).scalar()
|
||||
if count and not interrupted:
|
||||
interrupted = True
|
||||
if durable:
|
||||
await original_commit(transaction)
|
||||
if initial_cancel:
|
||||
asyncio.current_task().cancel()
|
||||
await asyncio.sleep(0)
|
||||
raise RuntimeError('synthetic-secret')
|
||||
await original_commit(transaction)
|
||||
|
||||
async def unavailable_reconciliation(statement, *args, **kwargs):
|
||||
nonlocal reconciliation_attempts
|
||||
if interrupted:
|
||||
reconciliation_attempts += 1
|
||||
if reconcile_cancel:
|
||||
# Repeated real cancellation must stop the batch, not start a
|
||||
# shield/retry loop or leave the current result as pending.
|
||||
asyncio.current_task().cancel()
|
||||
asyncio.current_task().cancel()
|
||||
await asyncio.sleep(0)
|
||||
raise RuntimeError('synthetic-secret')
|
||||
return await original_execute(statement, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(AsyncSessionTransaction, 'commit', interrupt_commit)
|
||||
monkeypatch.setattr(env.pm, 'execute_async', unavailable_reconciliation)
|
||||
task_id = (await env.svc.execute(context(), await selection(env, ('one', 'two'))))['task_id']
|
||||
task = env.ap.task_mgr.get_task_by_id(task_id)
|
||||
await asyncio.gather(task.task, return_exceptions=True)
|
||||
assert task.task.cancelled() is reconcile_cancel
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
|
||||
assert len(backups) == int(durable)
|
||||
assert task.task_context.metadata['results'][0] == {
|
||||
'pipeline_uuid': 'one',
|
||||
'state': 'activation_pending',
|
||||
'code': 'commit_outcome_unknown',
|
||||
}
|
||||
if initial_cancel or reconcile_cancel:
|
||||
assert reconciliation_attempts == 1
|
||||
assert task.task_context.metadata['results'][1] == {
|
||||
'pipeline_uuid': 'two',
|
||||
'state': 'failed',
|
||||
'code': 'operation_cancelled',
|
||||
}
|
||||
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
|
||||
assert configs['two'] == SOURCE
|
||||
assert 'synthetic-secret' not in str(task.task_context.metadata)
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('durable_retry_ack', [False, True])
|
||||
@pytest.mark.parametrize('reconcile_failure', [None, 'error', 'cancel'])
|
||||
async def test_cancel_activation_retry_reconciles_original_snapshot(
|
||||
env, monkeypatch, durable_retry_ack, reconcile_failure
|
||||
):
|
||||
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
||||
|
||||
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
|
||||
await execute(env)
|
||||
before_configs, before_backups = await rows(env)
|
||||
body = await selection(env, ('one', 'two'))
|
||||
env.ap.pipeline_mgr.publish_pipeline.reset_mock(side_effect=True)
|
||||
env.ap.pipeline_mgr.prepare_pipeline.reset_mock()
|
||||
original_commit = AsyncSessionTransaction.commit
|
||||
original_service_commit = env.svc._commit
|
||||
original_execute = env.pm.execute_async
|
||||
interrupted = False
|
||||
in_commit = False
|
||||
reconciliation_attempts = 0
|
||||
|
||||
async def service_commit(*args):
|
||||
nonlocal in_commit
|
||||
in_commit = True
|
||||
try:
|
||||
return await original_service_commit(*args)
|
||||
finally:
|
||||
in_commit = False
|
||||
|
||||
async def cancel_ack(transaction):
|
||||
nonlocal interrupted
|
||||
if in_commit and not interrupted:
|
||||
interrupted = True
|
||||
if durable_retry_ack:
|
||||
await original_commit(transaction)
|
||||
asyncio.current_task().cancel()
|
||||
await asyncio.sleep(0)
|
||||
await original_commit(transaction)
|
||||
|
||||
async def reconcile(statement, *args, **kwargs):
|
||||
nonlocal reconciliation_attempts
|
||||
if interrupted and not in_commit:
|
||||
reconciliation_attempts += 1
|
||||
if reconcile_failure == 'cancel':
|
||||
asyncio.current_task().cancel()
|
||||
await asyncio.sleep(0)
|
||||
if reconcile_failure == 'error':
|
||||
raise RuntimeError('synthetic-secret')
|
||||
return await original_execute(statement, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(env.svc, '_commit', service_commit)
|
||||
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_ack)
|
||||
monkeypatch.setattr(env.pm, 'execute_async', reconcile)
|
||||
task_id = (await env.svc.execute(context(), body))['task_id']
|
||||
task = env.ap.task_mgr.get_task_by_id(task_id)
|
||||
await asyncio.gather(task.task, return_exceptions=True)
|
||||
configs, backups = await rows(env)
|
||||
assert interrupted and configs == before_configs
|
||||
assert backups == before_backups
|
||||
assert backups[0]['state'] == 'activation_pending'
|
||||
assert reconciliation_attempts == 1
|
||||
assert task.task.cancelled() is (reconcile_failure == 'cancel')
|
||||
assert task.task_context.metadata['results'] == [
|
||||
{
|
||||
'pipeline_uuid': 'one',
|
||||
'state': 'activation_pending',
|
||||
'code': 'commit_outcome_unknown' if reconcile_failure else 'operation_cancelled',
|
||||
},
|
||||
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
|
||||
]
|
||||
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
assert 'synthetic-secret' not in str(task.task_context.metadata)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_prepare_keeps_original_and_stops_batch(env):
|
||||
async def cancel_prepare(*args):
|
||||
asyncio.current_task().cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
env.ap.pipeline_mgr.prepare_pipeline.side_effect = cancel_prepare
|
||||
task = await execute(env, await selection(env, ('one', 'two')))
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == configs['two'] == SOURCE and not backups
|
||||
assert all(
|
||||
r['state'] == 'failed' and r['code'] == 'operation_cancelled' for r in task.task_context.metadata['results']
|
||||
)
|
||||
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
async def execute_all(env, install_plugins=True):
|
||||
response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': install_plugins})
|
||||
task = env.ap.task_mgr.get_task_by_id(response['task_id'])
|
||||
await task.task
|
||||
return response, task.task_context.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_migrates_workspace_and_skips_completed_on_retry(env):
|
||||
response, metadata = await execute_all(env)
|
||||
assert response['pipeline_uuids'] == ['one', 'two']
|
||||
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
|
||||
configs, backups = await rows(env)
|
||||
assert configs['foreign'] == SOURCE
|
||||
assert len(backups) == 2
|
||||
with pytest.raises(env.m.MigrationError, match='nothing_to_migrate'):
|
||||
await execute_all(env)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_data_only_without_plugins_never_contacts_runtime_or_marketplace(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
env.ap.runner_registry.list_runners.side_effect = AssertionError('offline must not query runtime')
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('offline must not install'))
|
||||
_, metadata = await execute_all(env, install_plugins=False)
|
||||
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
|
||||
assert all(r['code'] == 'data_only' for r in metadata['results'])
|
||||
configs, backups = await rows(env)
|
||||
assert len(backups) == 2
|
||||
assert configs['one']['ai']['runner']['id'] == RID
|
||||
assert configs['foreign'] == SOURCE
|
||||
env.ap.plugin_connector.install_plugin.assert_not_awaited()
|
||||
env.ap.runner_registry.list_runners.assert_not_awaited()
|
||||
assert env.ap.pipeline_mgr.publish_pipeline.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
|
||||
async def install(source, info, task_context):
|
||||
assert info['plugin_version'] == '1.0'
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(PluginSetting).values(
|
||||
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True
|
||||
)
|
||||
)
|
||||
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
|
||||
_, metadata = await execute_all(env)
|
||||
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
|
||||
env.ap.plugin_connector.install_plugin.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_install_failure_preserves_sources_and_never_exposes_upstream_error(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=RuntimeError('secret-token'))
|
||||
_, metadata = await execute_all(env)
|
||||
assert all(r['code'] == 'plugin_install_failed' for r in metadata['results'])
|
||||
assert 'secret-token' not in str(metadata)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and not backups
|
||||
env.ap.plugin_connector.install_plugin.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_installation_cannot_migrate_edits_made_while_installing(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
|
||||
async def install(*args, **kwargs):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(PluginSetting).values(
|
||||
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True
|
||||
)
|
||||
)
|
||||
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(name='edited'))
|
||||
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
|
||||
_, metadata = await execute_all(env)
|
||||
assert metadata['results'][0]['code'] == 'preview_stale'
|
||||
assert metadata['results'][1]['state'] == 'migrated'
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE
|
||||
assert len(backups) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_has_no_fifty_pipeline_limit(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(LegacyPipeline),
|
||||
[
|
||||
dict(
|
||||
uuid=f'extra-{i}',
|
||||
workspace_uuid=WS,
|
||||
name=f'extra-{i}',
|
||||
description='bulk fixture',
|
||||
for_version='4.10',
|
||||
stages=[],
|
||||
config=SOURCE,
|
||||
extensions_preferences={'enable_all_plugins': True},
|
||||
)
|
||||
for i in range(51)
|
||||
],
|
||||
)
|
||||
response, metadata = await execute_all(env, install_plugins=False)
|
||||
assert len(response['pipeline_uuids']) == 53
|
||||
assert all(r['state'] == 'migrated' for r in metadata['results'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_rejects_duplicate_tasks_and_unconfirmed_requests(env):
|
||||
for body in [
|
||||
{'all': True, 'confirmed': False, 'install_plugins': True},
|
||||
{'all': True, 'confirmed': True, 'install_plugins': 'false'},
|
||||
{'all': True, 'confirmed': True, 'install_plugins': False, 'workspace_uuid': OTHER},
|
||||
]:
|
||||
with pytest.raises(env.m.MigrationError):
|
||||
await env.svc.execute(context(), body)
|
||||
env.svc._all_tasks.add(WS)
|
||||
with pytest.raises(env.m.MigrationError, match='migration_running'):
|
||||
await execute_all(env)
|
||||
env.svc._all_tasks.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('stage', ['discovery', 'install', 'validation'])
|
||||
async def test_disconnected_runtime_has_specific_error_without_modifying_sources(env, stage):
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeNotConnectedError
|
||||
|
||||
error = PluginRuntimeNotConnectedError('private connection diagnostics')
|
||||
if stage == 'discovery':
|
||||
env.ap.runner_registry.list_runners.side_effect = error
|
||||
elif stage == 'install':
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=error)
|
||||
else:
|
||||
env.svc._verify_runtime = AsyncMock(side_effect=error)
|
||||
_, metadata = await execute_all(env)
|
||||
assert all(r['code'] == 'plugin_runtime_unavailable' for r in metadata['results'])
|
||||
assert 'private connection diagnostics' not in str(metadata)
|
||||
configs, backups = await rows(env)
|
||||
assert configs['one'] == SOURCE and configs['two'] == SOURCE
|
||||
assert not backups
|
||||
|
||||
|
||||
def test_install_progress_records_stages_without_exposing_diagnostics():
|
||||
from langbot.pkg.api.http.service.pipeline_migration import MigrationInstallContext
|
||||
|
||||
ctx = MigrationInstallContext({'author': 'team', 'name': 'Runner', 'version': '1.0'})
|
||||
ctx.trace('private-token', action='downloading plugin package')
|
||||
ctx.metadata.update(download_current=120, download_total=240, progress_percent=15, url='private-token')
|
||||
ctx.set_current_action('validating plugin package')
|
||||
ctx.metadata['progress_percent'] = float('nan')
|
||||
ctx.finish('plugin_install_failed')
|
||||
assert ctx.progress['download_current'] == 120
|
||||
assert ctx.progress['progress_percent'] == 15
|
||||
assert [step['stage'] for step in ctx.progress['steps']] == ['checking', 'downloading', 'validating']
|
||||
assert all(step['finished_at'] is not None for step in ctx.progress['steps'])
|
||||
assert ctx.progress['status'] == 'failed'
|
||||
assert 'private-token' not in str(ctx.progress)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_progress_visible_while_download_is_running(env):
|
||||
import asyncio
|
||||
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def install(source, info, task_context):
|
||||
task_context.set_current_action('downloading plugin package')
|
||||
task_context.metadata.update(download_current=123, download_total=456)
|
||||
started.set()
|
||||
await release.wait()
|
||||
raise RuntimeError('private-token')
|
||||
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
|
||||
response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': True})
|
||||
task = env.ap.task_mgr.get_task_by_id(response['task_id'])
|
||||
try:
|
||||
await asyncio.wait_for(started.wait(), 3)
|
||||
await asyncio.sleep(0.25)
|
||||
progress = task.task_context.metadata['installations'][0]
|
||||
assert progress['status'] == 'installing'
|
||||
assert progress['download_current'] == 123
|
||||
assert progress['stage'] == 'downloading'
|
||||
finally:
|
||||
release.set()
|
||||
await task.task
|
||||
assert len(task.task_context.metadata['installations']) == 1
|
||||
assert progress['status'] == 'failed'
|
||||
assert 'private-token' not in str(task.task_context.to_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_marketplace_release_has_specific_migration_result(env):
|
||||
from langbot.pkg.plugin.connector import MarketplacePluginVersionNotFoundError
|
||||
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.delete(PluginSetting))
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(
|
||||
side_effect=MarketplacePluginVersionNotFoundError('private-token')
|
||||
)
|
||||
_, metadata = await execute_all(env)
|
||||
assert all(r['code'] == 'plugin_version_unavailable' for r in metadata['results'])
|
||||
assert metadata['installations'][0]['code'] == 'plugin_version_unavailable'
|
||||
assert 'private-token' not in str(metadata)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Actual planner + artifact descriptors + migration service + SQLite snapshots.
|
||||
|
||||
Vendor calls and runtime publication are fixtures. Database writes, snapshots,
|
||||
scoped service validation, and converter output are real. Browser tests consume
|
||||
this same synthetic contract, without requiring Python or a sibling checkout.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
import yaml
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
|
||||
from langbot.pkg.pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline
|
||||
from tests.unit_tests.api.service import test_pipeline_migration as base
|
||||
|
||||
ROOT = Path(__file__).parents[4]
|
||||
CONTRACT = json.loads((ROOT / 'web/tests/e2e/fixtures/runner-migration-contract.json').read_text())
|
||||
env = base.env
|
||||
|
||||
|
||||
@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner'])
|
||||
def test_portable_browser_contract_matches_actual_converter(case):
|
||||
assert CONTRACT['planner_version'] == PLANNER_VERSION
|
||||
assert plan_legacy_pipeline(case['source']) == case['plan']
|
||||
assert len(CONTRACT['cases']) == len(CONTRACT['plugins']) == 9
|
||||
for name in ('ai', 'output'):
|
||||
schema = yaml.safe_load((ROOT / f'src/langbot/templates/metadata/pipeline/{name}.yaml').read_text())
|
||||
assert CONTRACT[f'{name}_schema'] == schema
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('source_mode', ['local', 'cloud_projection'])
|
||||
@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner'])
|
||||
async def test_all_runner_service_roundtrip_retains_complete_backup(env, monkeypatch, case, source_mode):
|
||||
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
|
||||
source = copy.deepcopy(case['source'])
|
||||
plan = plan_legacy_pipeline(source)
|
||||
target = plan['target_plugin']
|
||||
artifact = next(
|
||||
item for item in CONTRACT['plugins'].values() if item['manifest']['metadata']['name'] == target['name']
|
||||
)
|
||||
schema = artifact['component']['spec']
|
||||
env.ap.runner_registry.list_runners.return_value = [
|
||||
RunnerDescriptor(
|
||||
id=plan['target_runner_id'],
|
||||
source='plugin',
|
||||
label={},
|
||||
plugin_author=target['author'],
|
||||
plugin_name=target['name'],
|
||||
plugin_version=target['version'],
|
||||
runner_name='default',
|
||||
usages=schema['usages'],
|
||||
config_schema=schema['config'],
|
||||
capabilities=schema.get('capabilities', {}),
|
||||
permissions=schema.get('permissions', {}),
|
||||
)
|
||||
]
|
||||
env.ap.logger = Mock()
|
||||
env.ap.tool_mgr = NS(
|
||||
get_resolved_tool_catalog=AsyncMock(return_value=[]), get_tool_schema=AsyncMock(return_value=('', {}))
|
||||
)
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda c: ModelProvider.metadata.create_all(c, tables=[ModelProvider.__table__, LLMModel.__table__])
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(ModelProvider).values(
|
||||
uuid='fixture-provider',
|
||||
workspace_uuid=base.WS,
|
||||
name='fixture',
|
||||
requester='test',
|
||||
base_url='https://synthetic.invalid',
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(LLMModel).values(
|
||||
uuid='synthetic-model', workspace_uuid=base.WS, name='fixture', provider_uuid='fixture-provider'
|
||||
)
|
||||
)
|
||||
await conn.execute(sa.update(base.Workspace).where(base.Workspace.uuid == base.WS).values(source=source_mode))
|
||||
await conn.execute(
|
||||
sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(base.PluginSetting).values(
|
||||
workspace_uuid=base.WS,
|
||||
plugin_author=target['author'],
|
||||
plugin_name=target['name'],
|
||||
enabled=True,
|
||||
install_info={'version': target['version']},
|
||||
)
|
||||
)
|
||||
before, snapshots = await base.rows(env)
|
||||
assert not snapshots
|
||||
preview = await env.svc.preview(base.context())
|
||||
selected = next(row for row in preview['items'] if row['pipeline_uuid'] == 'one')
|
||||
assert selected['state'] == 'ready', selected['blockers']
|
||||
assert selected['preview_token']
|
||||
assert await base.rows(env) == (before, snapshots)
|
||||
task = await base.execute(
|
||||
env, {'confirmed': True, 'items': [{key: selected[key] for key in ('pipeline_uuid', 'preview_token')}]}
|
||||
)
|
||||
assert task.task_context.metadata['results'] == [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}]
|
||||
configs, backups = await base.rows(env)
|
||||
assert configs['one'] == plan['config']
|
||||
assert set(configs['one']['ai']) == {'runner', 'runner_config'}
|
||||
assert configs['two'] == before['two'] and configs['foreign'] == before['foreign']
|
||||
assert len(backups) == 1
|
||||
assert backups[0]['source_snapshot']['config'] == source
|
||||
assert set(backups[0]['source_snapshot']['config']['ai']) == {'runner', *CONTRACT['legacy_sections']}
|
||||
assert backups[0]['state'] == 'active'
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Shared admission and real embedded artifact schema regressions."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from tests.unit_tests.api.service.test_pipeline_migration import (
|
||||
env as migration_env,
|
||||
execute,
|
||||
WS,
|
||||
SOURCE,
|
||||
RID,
|
||||
planner,
|
||||
)
|
||||
from langbot.pkg.agent.runner.interaction_store import InteractionStore, InteractionScopeError
|
||||
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def env(tmp_path, monkeypatch):
|
||||
async for value in migration_env.__wrapped__(tmp_path, monkeypatch):
|
||||
yield value
|
||||
|
||||
|
||||
async def request(env, **kwargs):
|
||||
return await InteractionStore(env.pm.get_db_engine()).create_request(
|
||||
interaction_id='form',
|
||||
run_id='run',
|
||||
binding_id='binding',
|
||||
runner_id=RID,
|
||||
processor_type='pipeline',
|
||||
processor_id='one',
|
||||
workspace_id=WS,
|
||||
request={},
|
||||
delivery_target={},
|
||||
expected_config=copy.deepcopy(SOURCE),
|
||||
authority_check=lambda: True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_writer_after_commit_rejected(env):
|
||||
original = env.svc._activate
|
||||
|
||||
async def activate(*args):
|
||||
with pytest.raises(InteractionScopeError):
|
||||
await request(env)
|
||||
return await original(*args)
|
||||
|
||||
env.svc._activate = activate
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
|
||||
with pytest.raises(InteractionScopeError):
|
||||
await request(env)
|
||||
async with env.engine.connect() as conn:
|
||||
assert not (await conn.execute(sa.select(AgentInteraction))).all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writer_requires_pipeline_authority(env):
|
||||
with pytest.raises(InteractionScopeError):
|
||||
await InteractionStore(env.engine).create_request(
|
||||
interaction_id='form',
|
||||
run_id='run',
|
||||
binding_id='binding',
|
||||
runner_id=RID,
|
||||
processor_type='pipeline',
|
||||
processor_id='one',
|
||||
workspace_id=WS,
|
||||
request={},
|
||||
delivery_target={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'folder,field,good,bad',
|
||||
[
|
||||
('LangflowAgent', 'tweaks', {'node': {'value': 1}}, ['not-an-object']),
|
||||
('WeKnoraAgent', 'knowledge-base-ids', ['kb-a'], [1]),
|
||||
],
|
||||
)
|
||||
async def test_embedded_artifact_schema_types(env, folder, field, good, bad):
|
||||
root = Path(__file__).parent / 'fixtures'
|
||||
artifact = 'langflow-agent' if folder == 'LangflowAgent' else 'weknora-agent'
|
||||
spec = {'config': json.loads((root / (artifact + '-artifact-schema.json')).read_text())}
|
||||
kind = 'json' if folder == 'LangflowAgent' else 'array[string]'
|
||||
schema = next(s for s in spec['config'] if s['type'] == kind)
|
||||
descriptor = env.ap.runner_registry.list_runners.return_value[0]
|
||||
descriptor.config_schema = [schema]
|
||||
plan = planner(SOURCE)
|
||||
plan['config']['ai']['runner_config'][RID] = {schema['name']: good}
|
||||
await env.svc._verify_runtime(NS(workspace_uuid=WS), plan)
|
||||
plan['config']['ai']['runner_config'][RID][schema['name']] = bad
|
||||
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
|
||||
await env.svc._verify_runtime(NS(workspace_uuid=WS), plan)
|
||||
|
||||
|
||||
def test_conversation_authority_is_captured_before_runner_returns():
|
||||
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
|
||||
|
||||
old, new = NS(), NS()
|
||||
config = planner(SOURCE)['config']
|
||||
query = NS(pipeline_config=config, pipeline_uuid='one', session=NS(using_conversation=new))
|
||||
binding = NS(
|
||||
processor_type='pipeline', processor_id='one', runner_id=RID, runner_config=config['ai']['runner_config'][RID]
|
||||
)
|
||||
authority = InteractionManager._pipeline_admission(
|
||||
binding,
|
||||
NS(id=RID),
|
||||
{
|
||||
'_query': query,
|
||||
'_pipeline_expected_config': copy.deepcopy(config),
|
||||
'_pipeline_conversation': old,
|
||||
},
|
||||
)
|
||||
assert authority['authority_check']() is False
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Migration must not abandon native or durable human-input requests."""
|
||||
|
||||
import copy
|
||||
import sys
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from tests.unit_tests.api.service import test_pipeline_migration as existing
|
||||
from tests.unit_tests.api.service.test_pipeline_migration import (
|
||||
context,
|
||||
selection,
|
||||
execute,
|
||||
rows,
|
||||
WS,
|
||||
OTHER,
|
||||
SOURCE,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
|
||||
|
||||
env = existing.env
|
||||
NATIVE = 'langbot.pkg.provider.runners.difysvapi'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def interaction_schema(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True))
|
||||
|
||||
|
||||
async def add_request(env, status='pending', workspace=WS, pipeline='one'):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(AgentInteraction).values(
|
||||
interaction_id='form',
|
||||
run_id=f'{workspace}-{pipeline}-{status}',
|
||||
binding_id='binding',
|
||||
runner_id='plugin:langbot-team/DifyAgent/default',
|
||||
processor_type='pipeline',
|
||||
processor_id=pipeline,
|
||||
workspace_id=workspace,
|
||||
status=status,
|
||||
request_json='{"secret":"private-form"}',
|
||||
callback_token_hash=f'{workspace}-{pipeline}-{status}',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def native(monkeypatch, workspace=WS, pipeline='one', instance='instance'):
|
||||
forms = {
|
||||
(instance, workspace, 1, 'bot', pipeline, 'adapter', 'person', 'actor'): {
|
||||
'private-token': {'inputs': {'secret': 'private-form'}}
|
||||
}
|
||||
}
|
||||
monkeypatch.setitem(sys.modules, NATIVE, NS(_PENDING_FORMS=forms))
|
||||
return forms
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('source', ['native', 'durable'])
|
||||
async def test_pending_preview_readonly_secret_free(env, monkeypatch, source):
|
||||
cache = native(monkeypatch) if source == 'native' else None
|
||||
if source == 'durable':
|
||||
await add_request(env)
|
||||
before = copy.deepcopy(cache)
|
||||
writes = []
|
||||
|
||||
def observe(_conn, _cursor, statement, *_args):
|
||||
if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
|
||||
writes.append(statement)
|
||||
|
||||
sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe)
|
||||
result = await env.svc.preview(context())
|
||||
item = result['items'][0]
|
||||
assert item['state'] == 'blocked'
|
||||
assert {'code': 'runtime.pending_interaction'} in item['blockers']
|
||||
assert item['preview_token'] is None
|
||||
assert result['items'][1]['state'] == 'ready'
|
||||
assert 'private-' not in str(result)
|
||||
assert cache == before and not writes
|
||||
env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status', ['submitted', 'cancelled', 'expired', 'delivery_failed'])
|
||||
async def test_terminal_requests_allow_migration(env, status):
|
||||
await add_request(env, status)
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('source', ['native', 'durable'])
|
||||
async def test_other_scope_does_not_block(env, monkeypatch, source):
|
||||
if source == 'native':
|
||||
native(monkeypatch, workspace=OTHER)
|
||||
else:
|
||||
await add_request(env, workspace=OTHER)
|
||||
await add_request(env, pipeline='two')
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('source', ['native', 'durable'])
|
||||
async def test_request_after_preview_stales_token(env, monkeypatch, source):
|
||||
body = await selection(env)
|
||||
if source == 'native':
|
||||
native(monkeypatch)
|
||||
else:
|
||||
await add_request(env)
|
||||
with pytest.raises(env.m.MigrationError, match='preview_stale'):
|
||||
await env.svc.execute(context(), body)
|
||||
assert not env.ap.task_mgr.tasks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('source', ['native', 'durable'])
|
||||
async def test_new_request_during_prepare_prevents_snapshot_and_reset(env, monkeypatch, source):
|
||||
conversation = NS(pipeline_uuid='one')
|
||||
session = NS(workspace_uuid=WS, instance_uuid='instance', using_conversation=conversation)
|
||||
env.ap.sess_mgr.session_list = [session]
|
||||
|
||||
async def prepare(*args):
|
||||
if source == 'native':
|
||||
native(monkeypatch)
|
||||
else:
|
||||
await add_request(env)
|
||||
return 'candidate'
|
||||
|
||||
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
|
||||
task = await execute(env)
|
||||
configs, snapshots = await rows(env)
|
||||
assert configs['one'] == SOURCE and not snapshots
|
||||
assert session.using_conversation is conversation
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'stale'
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activation_boundary_catches_new_request(env, monkeypatch):
|
||||
original = env.svc._commit
|
||||
|
||||
async def commit(*args):
|
||||
result = await original(*args)
|
||||
await add_request(env)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(env.svc, '_commit', commit)
|
||||
task = await execute(env)
|
||||
assert task.task_context.metadata['results'][0] == {
|
||||
'pipeline_uuid': 'one',
|
||||
'state': 'activation_pending',
|
||||
'code': 'runtime.pending_interaction',
|
||||
}
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
_, snapshots = await rows(env)
|
||||
assert snapshots[0]['state'] == 'activation_pending'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activation_retry_cannot_erase_pending_form(env):
|
||||
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('activation failed')
|
||||
await execute(env)
|
||||
body = await selection(env)
|
||||
before = await rows(env)
|
||||
await add_request(env)
|
||||
with pytest.raises(env.m.MigrationError, match='preview_stale'):
|
||||
await env.svc.execute(context(), body)
|
||||
assert await rows(env) == before
|
||||
assert (await env.svc.preview(context()))['items'][0]['state'] == 'blocked'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_interaction_table_fails_closed(env):
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.run_sync(lambda c: AgentInteraction.__table__.drop(c))
|
||||
result = await env.svc.preview(context())
|
||||
assert result['items'][0]['state'] == 'blocked'
|
||||
assert {'code': 'runtime.pending_interaction'} in result['items'][0]['blockers']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_native_scope_fails_closed_without_payload(env, monkeypatch):
|
||||
native(monkeypatch, workspace='')
|
||||
result = await env.svc.preview(context())
|
||||
assert result['items'][0]['state'] == 'blocked'
|
||||
assert 'private-' not in str(result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_state_change_requires_fresh_preview(env):
|
||||
await add_request(env, 'submitted')
|
||||
body = await selection(env)
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(sa.update(AgentInteraction).values(status='cancelled'))
|
||||
with pytest.raises(env.m.MigrationError, match='preview_stale'):
|
||||
await env.svc.execute(context(), body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_atomic_boundary_not_advertised_ready(env, monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
monkeypatch.setattr(env.pm, 'get_db_engine', lambda: NS(dialect=NS(name='unsupported')))
|
||||
monkeypatch.setattr(env.pm, 'execute_async', AsyncMock(return_value=NS(all=lambda: [])))
|
||||
_, blocked = await env.svc._interaction_state(context(), 'one')
|
||||
assert blocked
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch):
|
||||
original = env.pm.execute_async
|
||||
|
||||
async def execute_sql(statement, *args, **kwargs):
|
||||
result = await original(statement, *args, **kwargs)
|
||||
if isinstance(statement, sa.sql.dml.Insert) and statement.table.name == 'pipeline_migration_snapshots':
|
||||
native(monkeypatch)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(env.pm, 'execute_async', execute_sql)
|
||||
await execute(env)
|
||||
configs, snapshots = await rows(env)
|
||||
assert configs['one'] == SOURCE and not snapshots
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
|
||||
|
||||
|
||||
# Also exercise the unchanged regression assertions with this module's
|
||||
# interaction-schema fixture. The original tests remain in their own module.
|
||||
|
||||
for _name, _test in vars(existing).items():
|
||||
if _name.startswith('test_') and callable(_test):
|
||||
globals()['test_existing_' + _name[5:]] = _test
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Real scoped database checks against the reviewed LocalAgent descriptor."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
|
||||
from langbot.pkg.entity.persistence.rag import KnowledgeBase
|
||||
from langbot.pkg.entity.persistence.mcp import MCPServer
|
||||
from tests.unit_tests.api.service import test_pipeline_migration as base
|
||||
from tests.unit_tests.api.service.test_pipeline_migration import context, WS, OTHER
|
||||
|
||||
env = base.env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local(env):
|
||||
spec = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_local_schema.json').read_text())
|
||||
rid = 'plugin:langbot-team/LocalAgent/default'
|
||||
descriptor = RunnerDescriptor(
|
||||
id=rid,
|
||||
source='plugin',
|
||||
label={},
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='LocalAgent',
|
||||
runner_name='default',
|
||||
plugin_version='reviewed',
|
||||
usages=['agent'],
|
||||
config_schema=spec['config'],
|
||||
capabilities=spec['capabilities'],
|
||||
permissions=spec['permissions'],
|
||||
)
|
||||
env.ap.runner_registry.list_runners.return_value = [descriptor]
|
||||
env.ap.logger = Mock()
|
||||
env.ap.tool_mgr = NS(
|
||||
get_resolved_tool_catalog=AsyncMock(
|
||||
return_value=[{'name': 'scoped_tool', 'source': 'mcp', 'source_id': 'mcp'}]
|
||||
),
|
||||
get_tool_schema=AsyncMock(return_value=('', {})),
|
||||
)
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda c: ModelProvider.metadata.create_all(
|
||||
c, tables=[ModelProvider.__table__, LLMModel.__table__, KnowledgeBase.__table__, MCPServer.__table__]
|
||||
)
|
||||
)
|
||||
for ws, suffix in [(WS, ''), (OTHER, '_foreign')]:
|
||||
await conn.execute(
|
||||
sa.insert(ModelProvider).values(
|
||||
uuid='provider' + suffix,
|
||||
workspace_uuid=ws,
|
||||
name='provider',
|
||||
requester='test',
|
||||
base_url='https://synthetic.invalid',
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(LLMModel).values(
|
||||
uuid='model' + suffix, workspace_uuid=ws, name='model', provider_uuid='provider' + suffix
|
||||
)
|
||||
)
|
||||
await conn.execute(sa.insert(KnowledgeBase).values(uuid='kb' + suffix, workspace_uuid=ws, name='kb'))
|
||||
await conn.execute(
|
||||
sa.insert(MCPServer).values(
|
||||
uuid='mcp' + suffix, workspace_uuid=ws, name='mcp' + suffix, mode='remote', enable=True
|
||||
)
|
||||
)
|
||||
config = {item['name']: copy.deepcopy(item['default']) for item in spec['config'] if 'default' in item}
|
||||
config.update(
|
||||
{
|
||||
'model': {'primary': 'model', 'fallbacks': [], 'reasoning': {}},
|
||||
'knowledge-bases': ['kb'],
|
||||
'tools': ['scoped_tool'],
|
||||
'enable-all-tools': False,
|
||||
'mcp-resources': [{'server_uuid': 'mcp', 'uri': 'test://document', 'enabled': True}],
|
||||
'mcp-resource-agent-read-enabled': True,
|
||||
}
|
||||
)
|
||||
plan = {
|
||||
'target_runner_id': rid,
|
||||
'target_plugin': {'version': 'reviewed'},
|
||||
'config': {'ai': {'runner': {'id': rid}, 'runner_config': {rid: config}}},
|
||||
}
|
||||
return env, descriptor, config, plan
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_local_resources_and_real_descriptor_options(local):
|
||||
env, _, _, plan = local
|
||||
async with env.pm.tenant_scope(WS):
|
||||
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
||||
env.ap.tool_mgr.get_resolved_tool_catalog.assert_awaited_once()
|
||||
ctx = env.ap.tool_mgr.get_resolved_tool_catalog.call_args.args[0]
|
||||
assert ctx.workspace_uuid == WS
|
||||
assert env.pm.current_session() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('resource', ['model', 'kb', 'mcp', 'tool'])
|
||||
async def test_foreign_or_unresolved_resource_is_denied(local, resource):
|
||||
env, _, config, plan = local
|
||||
if resource == 'model':
|
||||
config['model']['primary'] = 'model_foreign'
|
||||
elif resource == 'kb':
|
||||
config['knowledge-bases'] = ['kb_foreign']
|
||||
elif resource == 'mcp':
|
||||
config['mcp-resources'][0]['server_uuid'] = 'mcp_foreign'
|
||||
else:
|
||||
config['tools'] = ['foreign_tool']
|
||||
async with env.pm.tenant_scope(WS):
|
||||
with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'):
|
||||
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('declared', [False, True])
|
||||
async def test_null_requires_explicit_descriptor_nullable(local, declared):
|
||||
env, descriptor, config, plan = local
|
||||
config['timeout'] = None
|
||||
field = next(f for f in descriptor.config_schema if f['name'] == 'timeout')
|
||||
field['nullable'] = declared
|
||||
async with env.pm.tenant_scope(WS):
|
||||
if declared:
|
||||
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
||||
else:
|
||||
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
|
||||
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_plugin_version_is_rejected_before_resource_resolution(local):
|
||||
env, descriptor, _, plan = local
|
||||
descriptor.plugin_version = 'old'
|
||||
async with env.pm.tenant_scope(WS):
|
||||
with pytest.raises(env.m.MigrationError, match='plugin_version_incompatible'):
|
||||
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
||||
env.ap.tool_mgr.get_resolved_tool_catalog.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_descriptor_migrates_through_detached_task(local, monkeypatch):
|
||||
env, _, _, plan = local
|
||||
|
||||
def planner(config, extensions_preferences=None):
|
||||
result = base.planner(config, extensions_preferences)
|
||||
if result['state'] == 'ready':
|
||||
result.update(copy.deepcopy(plan))
|
||||
result['target_plugin'].update(author='langbot-team', name='LocalAgent')
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', planner)
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(base.PluginSetting).values(
|
||||
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='LocalAgent', enabled=True
|
||||
)
|
||||
)
|
||||
task = await base.execute(env)
|
||||
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
|
||||
configs, snapshots = await base.rows(env)
|
||||
assert configs['one'] == plan['config']
|
||||
assert len(snapshots) == 1 and snapshots[0]['source_snapshot']['config'] == base.SOURCE
|
||||
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'template',
|
||||
[
|
||||
None,
|
||||
'{global}',
|
||||
'{launcher_type}_{launcher_id}',
|
||||
'{launcher_type}_{launcher_id}_{sender_id}',
|
||||
'{launcher_type}_{launcher_id}_{conversation_id}',
|
||||
'{query_id}',
|
||||
'{bot_id}_{launcher_type}_{launcher_id}',
|
||||
'project-{project}',
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize('install_plugins', [False, True])
|
||||
async def test_box_templates_roundtrip_in_both_migration_modes(local, monkeypatch, template, install_plugins):
|
||||
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
|
||||
|
||||
env, descriptor, _, _ = local
|
||||
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
|
||||
section = {'model': 'model', 'prompt': [], 'enable-all-tools': False, 'tools': []}
|
||||
if template is not None:
|
||||
section['box-session-id-template'] = template
|
||||
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': section}}
|
||||
plan = plan_legacy_pipeline(source)
|
||||
descriptor.plugin_version = plan['target_plugin']['version']
|
||||
async with env.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source)
|
||||
)
|
||||
await conn.execute(sa.delete(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'two'))
|
||||
if install_plugins:
|
||||
await conn.execute(
|
||||
sa.insert(base.PluginSetting).values(
|
||||
workspace_uuid=WS,
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='LocalAgent',
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
if not install_plugins:
|
||||
env.ap.runner_registry.list_runners.side_effect = AssertionError('Data-only must work offline')
|
||||
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('Data-only must not install'))
|
||||
_, result = await base.execute_all(env, install_plugins=install_plugins)
|
||||
assert result['results'] == [
|
||||
{
|
||||
'pipeline_uuid': 'one',
|
||||
'state': 'migrated',
|
||||
'code': None if install_plugins else 'data_only',
|
||||
}
|
||||
]
|
||||
configs, snapshots = await base.rows(env)
|
||||
saved = configs['one']['ai']['runner_config'][plan['target_runner_id']]
|
||||
effective = {field['name']: field['default'] for field in descriptor.config_schema if 'default' in field}
|
||||
effective.update(saved)
|
||||
assert effective['box-enabled'] is True
|
||||
assert effective['box-session-id-template'] == (template or '{launcher_type}_{launcher_id}')
|
||||
assert snapshots[0]['source_snapshot']['config'] == source
|
||||
assert 'box-session-id-template' not in configs['one']['ai']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('allow_custom', [False, None, 'true'])
|
||||
async def test_select_requires_explicit_custom_support(local, allow_custom):
|
||||
env, descriptor, config, plan = local
|
||||
field = next(f for f in descriptor.config_schema if f['name'] == 'box-session-id-template')
|
||||
field['allow_custom'] = allow_custom
|
||||
config['box-session-id-template'] = '{bot_id}_{launcher_id}'
|
||||
async with env.pm.tenant_scope(WS):
|
||||
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
|
||||
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Provider deletion uses real SQLite transactions and real runtime cache cleanup."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import quart
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvidersRouterGroup
|
||||
from langbot.pkg.api.http.service.provider import ModelProviderService
|
||||
from langbot.pkg.entity.persistence.model import CodexCredential, EmbeddingModel, LLMModel, ModelProvider, RerankModel
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
MODEL_TYPES = (LLMModel, EmbeddingModel, RerankModel)
|
||||
TABLES = (*MODEL_TYPES, CodexCredential, ModelProvider)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def deletion(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "cascade.db"}')
|
||||
|
||||
@sa.event.listens_for(engine.sync_engine, 'connect')
|
||||
def enable_foreign_keys(connection, _record):
|
||||
connection.execute('PRAGMA foreign_keys=ON')
|
||||
|
||||
ap = SimpleNamespace(logger=Mock())
|
||||
pm = ap.persistence_mgr = PersistenceManager(ap)
|
||||
pm.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
manager = ap.model_mgr = ModelManager(ap)
|
||||
contexts = {workspace: ExecutionContext('instance', workspace, 1) for workspace in ('a', 'b')}
|
||||
# Only execution binding discovery is stubbed; cache indexing/removal/close is real.
|
||||
manager.resolve_execution_context = AsyncMock(side_effect=lambda context: contexts[context])
|
||||
service = ModelProviderService(ap)
|
||||
closed = []
|
||||
|
||||
async def snapshot():
|
||||
async with engine.connect() as conn:
|
||||
return {
|
||||
table.__tablename__: [dict(row) for row in (await conn.execute(sa.select(table))).mappings()]
|
||||
for table in TABLES
|
||||
}
|
||||
|
||||
async with engine.begin() as conn:
|
||||
for table in (User, Workspace, ModelProvider, CodexCredential, *MODEL_TYPES):
|
||||
await conn.run_sync(table.__table__.create)
|
||||
for workspace in contexts:
|
||||
await conn.execute(
|
||||
sa.insert(Workspace).values(
|
||||
uuid=workspace,
|
||||
instance_uuid='instance',
|
||||
name=workspace,
|
||||
slug=workspace,
|
||||
source='cloud_projection',
|
||||
)
|
||||
)
|
||||
for provider, workspace in (('target', 'a'), ('neighbor', 'a'), ('foreign', 'b'), ('empty', 'a')):
|
||||
await conn.execute(
|
||||
sa.insert(ModelProvider).values(
|
||||
uuid=provider,
|
||||
workspace_uuid=workspace,
|
||||
name=provider,
|
||||
requester='openai-codex',
|
||||
base_url='https://chatgpt.com/backend-api/codex',
|
||||
api_keys=[],
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(CodexCredential).values(
|
||||
provider_uuid=provider,
|
||||
workspace_uuid=workspace,
|
||||
payload={'synthetic': provider},
|
||||
)
|
||||
)
|
||||
|
||||
async def close(provider=provider):
|
||||
# A separate connection must observe the durable deletion before close runs.
|
||||
state = await snapshot()
|
||||
assert all(row['uuid'] != provider for row in state['model_providers'])
|
||||
assert pm.current_session() is None
|
||||
closed.append(provider)
|
||||
|
||||
runtime = SimpleNamespace(requester=SimpleNamespace(aclose=AsyncMock(side_effect=close)))
|
||||
manager._cache_set(manager.provider_dict, manager._cache_key(contexts[workspace], provider), runtime)
|
||||
if provider == 'empty':
|
||||
continue
|
||||
for model_type, cache in zip(
|
||||
MODEL_TYPES,
|
||||
(
|
||||
manager.llm_model_dict,
|
||||
manager.embedding_model_dict,
|
||||
manager.rerank_model_dict,
|
||||
),
|
||||
):
|
||||
for index in range(2):
|
||||
uuid = f'{provider}-{model_type.__tablename__}-{index}'
|
||||
await conn.execute(
|
||||
sa.insert(model_type).values(
|
||||
uuid=uuid,
|
||||
workspace_uuid=workspace,
|
||||
provider_uuid=provider,
|
||||
name=uuid,
|
||||
)
|
||||
)
|
||||
manager._cache_set(cache, manager._cache_key(contexts[workspace], uuid), object())
|
||||
initial = await snapshot()
|
||||
initial_caches = [
|
||||
dict(cache)
|
||||
for cache in (
|
||||
manager.provider_dict,
|
||||
manager.llm_model_dict,
|
||||
manager.embedding_model_dict,
|
||||
manager.rerank_model_dict,
|
||||
)
|
||||
]
|
||||
try:
|
||||
yield SimpleNamespace(
|
||||
ap=ap,
|
||||
pm=pm,
|
||||
engine=engine,
|
||||
service=service,
|
||||
manager=manager,
|
||||
snapshot=snapshot,
|
||||
initial=initial,
|
||||
initial_caches=initial_caches,
|
||||
closed=closed,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def assert_caches_unchanged(deletion):
|
||||
assert deletion.closed == []
|
||||
assert deletion.initial_caches == [
|
||||
dict(cache)
|
||||
for cache in (
|
||||
deletion.manager.provider_dict,
|
||||
deletion.manager.llm_model_dict,
|
||||
deletion.manager.embedding_model_dict,
|
||||
deletion.manager.rerank_model_dict,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [PersistenceMode.OSS_COMPAT, PersistenceMode.CLOUD_RUNTIME])
|
||||
async def test_cascade_deletes_all_model_types_and_credentials_after_commit(deletion, mode):
|
||||
deletion.pm.mode = mode
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
state = await deletion.snapshot()
|
||||
for table, rows in deletion.initial.items():
|
||||
identity = 'uuid' if table == 'model_providers' else 'provider_uuid'
|
||||
assert state[table] == [row for row in rows if row[identity] != 'target']
|
||||
assert deletion.closed == ['target']
|
||||
deletion.ap.logger.warning.assert_not_called()
|
||||
for cache in (
|
||||
deletion.manager.provider_dict,
|
||||
deletion.manager.llm_model_dict,
|
||||
deletion.manager.embedding_model_dict,
|
||||
deletion.manager.rerank_model_dict,
|
||||
):
|
||||
assert all(not key[-1].startswith('target') for key in cache)
|
||||
assert any(key[1] == 'b' for key in cache)
|
||||
assert any(key[-1].startswith('neighbor') for key in cache)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('model_type', MODEL_TYPES)
|
||||
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}])
|
||||
async def test_default_guard_preserves_each_model_type(deletion, model_type, kwargs):
|
||||
async with deletion.engine.begin() as conn:
|
||||
for other in MODEL_TYPES:
|
||||
if other is not model_type:
|
||||
await conn.execute(sa.delete(other).where(other.provider_uuid == 'target'))
|
||||
before = await deletion.snapshot()
|
||||
with pytest.raises(ValueError, match='models still reference it'):
|
||||
await deletion.service.delete_provider('a', 'target', **kwargs)
|
||||
assert await deletion.snapshot() == before
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}, {'cascade': True}])
|
||||
async def test_empty_provider_deletes_credentials_with_or_without_cascade(deletion, kwargs):
|
||||
await deletion.service.delete_provider('a', 'empty', **kwargs)
|
||||
state = await deletion.snapshot()
|
||||
assert all(row['uuid'] != 'empty' for row in state['model_providers'])
|
||||
assert all(row['provider_uuid'] != 'empty' for row in state['codex_credentials'])
|
||||
assert deletion.closed == ['empty']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('provider', ['foreign', 'missing'])
|
||||
@pytest.mark.parametrize('cascade', [False, True])
|
||||
async def test_foreign_and_missing_provider_are_non_enumerating(deletion, provider, cascade):
|
||||
with pytest.raises(WorkspaceNotFoundError, match='Provider not found'):
|
||||
await deletion.service.delete_provider('a', provider, cascade=cascade)
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cascade', [False, True])
|
||||
async def test_cloud_managed_provider_cannot_be_deleted(deletion, cascade):
|
||||
async with deletion.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.update(ModelProvider)
|
||||
.where(ModelProvider.uuid == 'target')
|
||||
.values(
|
||||
requester='space-chat-completions',
|
||||
)
|
||||
)
|
||||
before = await deletion.snapshot()
|
||||
deletion.pm.mode = PersistenceMode.CLOUD_RUNTIME
|
||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||
await deletion.service.delete_provider('a', 'target', cascade=cascade)
|
||||
assert await deletion.snapshot() == before
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure_table', ['embedding_models', 'codex_credentials', 'model_providers'])
|
||||
async def test_database_failure_rolls_back_all_rows_without_runtime_cleanup(deletion, failure_table):
|
||||
async with deletion.engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
f'CREATE TRIGGER fail_delete BEFORE DELETE ON {failure_table} '
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END"
|
||||
)
|
||||
with pytest.raises(sa.exc.IntegrityError, match='injected delete failure'):
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rollback', [False, True])
|
||||
async def test_nested_transaction_defers_cleanup_until_outer_commit(deletion, rollback):
|
||||
class Abort(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
async with deletion.pm.tenant_uow('a'):
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
assert_caches_unchanged(deletion)
|
||||
if rollback:
|
||||
raise Abort
|
||||
except Abort:
|
||||
pass
|
||||
tasks = tuple(deletion.service._deletion_tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
if rollback:
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
else:
|
||||
assert deletion.closed == ['target']
|
||||
deletion.ap.logger.warning.assert_not_called()
|
||||
|
||||
|
||||
async def test_cascade_ignores_foreign_workspace_references_even_without_foreign_keys(deletion):
|
||||
async with deletion.engine.connect() as conn:
|
||||
await conn.exec_driver_sql('PRAGMA foreign_keys=OFF')
|
||||
for model_type in MODEL_TYPES:
|
||||
await conn.execute(
|
||||
sa.update(model_type)
|
||||
.where(model_type.workspace_uuid == 'b')
|
||||
.values(
|
||||
provider_uuid='target',
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
state = await deletion.snapshot()
|
||||
for model_type in MODEL_TYPES:
|
||||
assert len([row for row in state[model_type.__tablename__] if row['workspace_uuid'] == 'b']) == 2
|
||||
assert all(row['provider_uuid'] != 'target' for row in state['codex_credentials'])
|
||||
assert deletion.closed == ['target']
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def route_app(deletion):
|
||||
ap = deletion.ap
|
||||
ap.user_service = SimpleNamespace(
|
||||
get_authenticated_account=AsyncMock(
|
||||
return_value=SimpleNamespace(uuid='account', user='[email protected]'),
|
||||
)
|
||||
)
|
||||
membership = SimpleNamespace(uuid='membership', role='owner', projection_revision=0)
|
||||
ap.workspace_collaboration_service = SimpleNamespace(
|
||||
resolve_account_workspace=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workspace=SimpleNamespace(uuid='a'),
|
||||
membership=membership,
|
||||
execution=SimpleNamespace(instance_uuid='instance', placement_generation=1),
|
||||
),
|
||||
)
|
||||
)
|
||||
ap.provider_service = SimpleNamespace(delete_provider=AsyncMock())
|
||||
app = quart.Quart(__name__)
|
||||
await ModelProvidersRouterGroup(ap, app).initialize()
|
||||
return app.test_client(), ap.provider_service.delete_provider, membership
|
||||
|
||||
|
||||
@pytest.mark.parametrize('query, expected', [('', None), ('?cascade=true', True), ('?cascade=false', False)])
|
||||
async def test_route_passes_explicit_cascade_and_trusted_workspace(route_app, query, expected):
|
||||
client, delete, _ = route_app
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/target' + query,
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert delete.await_count == 1
|
||||
assert delete.await_args.args[0].workspace_uuid == 'a'
|
||||
assert delete.await_args.args[1] == 'target'
|
||||
assert delete.await_args.kwargs == ({} if expected is None else {'cascade': expected})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'query',
|
||||
[
|
||||
'?cascade=',
|
||||
'?cascade',
|
||||
'?cascade=TRUE',
|
||||
'?cascade=1',
|
||||
'?cascade=yes',
|
||||
'?cascade=null',
|
||||
'?cascade=%20true',
|
||||
'?cascade=true&cascade=false',
|
||||
'?cascade=true&cascade=true',
|
||||
],
|
||||
)
|
||||
async def test_route_rejects_invalid_or_duplicate_cascade_before_deletion(route_app, query):
|
||||
client, delete, _ = route_app
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/target' + query,
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
delete.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['viewer', 'operator'])
|
||||
async def test_cascade_requires_workspace_resource_manage_permission(route_app, role):
|
||||
client, delete, membership = route_app
|
||||
membership.role = role
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/target?cascade=true',
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
delete.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'provider, query, status',
|
||||
[
|
||||
('target', '', 400),
|
||||
('target', '?cascade=false', 400),
|
||||
('target', '?cascade=true', 200),
|
||||
('foreign', '?cascade=true', 404),
|
||||
('missing', '?cascade=true', 404),
|
||||
],
|
||||
)
|
||||
async def test_route_to_real_sqlite_service(deletion, route_app, provider, query, status):
|
||||
client, _, _ = route_app
|
||||
deletion.ap.provider_service = deletion.service
|
||||
# The route forwards RequestContext, unlike the string-context service tests.
|
||||
deletion.manager.resolve_execution_context = AsyncMock(
|
||||
side_effect=lambda context: ExecutionContext(
|
||||
context.instance_uuid,
|
||||
context.workspace_uuid,
|
||||
context.placement_generation,
|
||||
)
|
||||
)
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/' + provider + query,
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == status
|
||||
if status == 200:
|
||||
assert deletion.closed == ['target']
|
||||
for model_type in MODEL_TYPES:
|
||||
assert all(
|
||||
row['provider_uuid'] != 'target' for row in (await deletion.snapshot())[model_type.__tablename__]
|
||||
)
|
||||
else:
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
@@ -14,11 +14,12 @@ Source: src/langbot/pkg/api/http/service/provider.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langbot.pkg.api.http.service.provider import ModelProviderService
|
||||
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
|
||||
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
@@ -383,112 +384,35 @@ class TestModelProviderServiceUpdateProvider:
|
||||
|
||||
|
||||
class TestModelProviderServiceDeleteProvider:
|
||||
"""Tests for delete_provider method."""
|
||||
|
||||
async def test_delete_provider_with_llm_models_raises_error(self):
|
||||
"""Raises ValueError when LLM models reference provider."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Mock LLM model exists - only return LLM result since that's first check
|
||||
llm_result = _create_mock_result([], first_item=_create_mock_llm_model())
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=llm_result)
|
||||
"""Fast guard coverage; real transaction/cache behavior is in test_provider_cascade."""
|
||||
|
||||
@pytest.mark.parametrize('label', ['LLM', 'Embedding', 'Rerank', None])
|
||||
async def test_delete_provider_requires_no_references(self, label):
|
||||
provider_result = Mock()
|
||||
provider_result.first.return_value = SimpleNamespace(requester='openai')
|
||||
results = [provider_result]
|
||||
for model_label in ('LLM', 'Embedding', 'Rerank'):
|
||||
result = Mock()
|
||||
result.scalars.return_value = ['model'] if label == model_label else []
|
||||
results.append(result)
|
||||
results.extend([Mock(rowcount=1), Mock(rowcount=1)])
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(
|
||||
execute_async=AsyncMock(side_effect=results),
|
||||
tenant_uow=lambda _: nullcontext(),
|
||||
tenant_scope=lambda _: nullcontext(),
|
||||
current_session=lambda: None,
|
||||
),
|
||||
model_mgr=SimpleNamespace(remove_provider=AsyncMock()),
|
||||
)
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Cannot delete provider: LLM models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-llm')
|
||||
|
||||
async def test_delete_provider_with_embedding_models_raises_error(self):
|
||||
"""Raises ValueError when Embedding models reference provider."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Create results for each check type
|
||||
llm_result = Mock()
|
||||
llm_result.first = Mock(return_value=None) # No LLM models
|
||||
embedding_result = Mock()
|
||||
embedding_result.first = Mock(return_value=Mock(spec=EmbeddingModel)) # Has embedding model
|
||||
rerank_result = Mock()
|
||||
rerank_result.first = Mock(return_value=None)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return llm_result
|
||||
elif call_count == 2:
|
||||
return embedding_result
|
||||
return rerank_result
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute & Verify - should raise embedding error (LLM check passes, embedding check fails)
|
||||
with pytest.raises(ValueError, match='Cannot delete provider: Embedding models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-embedding')
|
||||
|
||||
async def test_delete_provider_with_rerank_models_raises_error(self):
|
||||
"""Raises ValueError when Rerank models reference provider."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Create results for each check type
|
||||
llm_result = Mock()
|
||||
llm_result.first = Mock(return_value=None) # No LLM models
|
||||
embedding_result = Mock()
|
||||
embedding_result.first = Mock(return_value=None) # No embedding models
|
||||
rerank_result = Mock()
|
||||
rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return llm_result
|
||||
elif call_count == 2:
|
||||
return embedding_result
|
||||
return rerank_result
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute & Verify - should raise rerank error (LLM and embedding checks pass, rerank check fails)
|
||||
with pytest.raises(ValueError, match='Cannot delete provider: Rerank models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-rerank')
|
||||
|
||||
async def test_delete_provider_no_models_success(self):
|
||||
"""Deletes provider when no models reference it."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.model_mgr = SimpleNamespace()
|
||||
ap.model_mgr.remove_provider = AsyncMock()
|
||||
|
||||
# Mock no models reference provider
|
||||
empty_result = Mock()
|
||||
empty_result.first = Mock(return_value=None)
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=empty_result)
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-no-models')
|
||||
|
||||
# Verify - delete and remove called
|
||||
ap.model_mgr.remove_provider.assert_called_once_with(WORKSPACE_UUID, 'provider-no-models')
|
||||
if label is not None:
|
||||
with pytest.raises(ValueError, match=f'Cannot delete provider: {label} models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||
ap.model_mgr.remove_provider.assert_not_awaited()
|
||||
else:
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||
ap.model_mgr.remove_provider.assert_awaited_once_with(WORKSPACE_UUID, 'provider')
|
||||
|
||||
|
||||
class TestModelProviderServiceGetProviderModelCounts:
|
||||
@@ -1045,15 +969,18 @@ class TestCloudManagedProviderProtection:
|
||||
|
||||
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
||||
service = self._service()
|
||||
service.get_provider = AsyncMock(
|
||||
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
|
||||
)
|
||||
service.get_provider = AsyncMock(return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER})
|
||||
|
||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
service.ap.persistence_mgr.tenant_uow = lambda _: nullcontext()
|
||||
result = Mock()
|
||||
result.first.return_value = SimpleNamespace(requester=SYSTEM_REQUESTER)
|
||||
service.ap.persistence_mgr.execute_async.return_value = result
|
||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
assert service.ap.persistence_mgr.execute_async.await_count == 1
|
||||
|
||||
async def test_oss_does_not_reserve_space_requester(self):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
||||
|
||||
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
|
||||
result = service.get_oauth_authorize_url('http://localhost/callback')
|
||||
|
||||
# Verify
|
||||
assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
|
||||
query = parse_qs(urlsplit(result).query)
|
||||
assert query['redirect_uri'] == ['http://localhost/callback']
|
||||
assert query['code_contract'] == ['redirect-v1']
|
||||
assert 'https://space.langbot.app/auth/authorize' in result
|
||||
|
||||
def test_get_oauth_authorize_url_with_state(self):
|
||||
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
|
||||
'auth_code',
|
||||
['workspace-1'],
|
||||
{'workspace-1': 1_700_000_000},
|
||||
redirect_uri='https://oss.example/auth/space/callback',
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result['access_token'] == 'new_access_token'
|
||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||
'code': 'auth_code',
|
||||
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||
'instance_id': constants.instance_id,
|
||||
'workspace_uuids': ['workspace-1'],
|
||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||
@@ -823,7 +827,9 @@ class TestSpaceServiceGetModels:
|
||||
class TestSpaceServiceGetModelSelection:
|
||||
"""Tests for availability-ranked model selection."""
|
||||
|
||||
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||
@pytest.mark.parametrize(
|
||||
'response_shape', ['direct', 'models-envelope', 'availability-wrapper', 'legacy-flat-wrapper']
|
||||
)
|
||||
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||
service = SpaceService(ap)
|
||||
@@ -859,6 +865,8 @@ class TestSpaceServiceGetModelSelection:
|
||||
}
|
||||
for index, model in enumerate(models)
|
||||
]
|
||||
elif response_shape == 'legacy-flat-wrapper':
|
||||
data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)]
|
||||
else:
|
||||
data = models
|
||||
payload = {'code': 0, 'data': data}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Unit tests for Passkey WebAuthn service operations in UserService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.entity.persistence.user import AccountStatus, User
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestPasskeyChallengeLifecycle:
|
||||
async def test_challenge_issuance_and_consumption(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
token, challenge_bytes = await service.issue_passkey_challenge(
|
||||
purpose='register',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
account_uuid='acc-123',
|
||||
user_email='[email protected]',
|
||||
)
|
||||
|
||||
assert len(token) > 20
|
||||
assert len(challenge_bytes) == 32
|
||||
|
||||
data = await service.consume_passkey_challenge(token, 'register')
|
||||
assert data.challenge == challenge_bytes
|
||||
assert data.rp_id == 'localhost'
|
||||
assert data.origin == 'http://localhost:3000'
|
||||
assert data.account_uuid == 'acc-123'
|
||||
assert data.user_email == '[email protected]'
|
||||
|
||||
# Replay should fail
|
||||
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
|
||||
await service.consume_passkey_challenge(token, 'register')
|
||||
|
||||
async def test_challenge_purpose_mismatch_fails(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
token, _ = await service.issue_passkey_challenge(
|
||||
purpose='register',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='Passkey challenge purpose mismatch'):
|
||||
await service.consume_passkey_challenge(token, 'auth')
|
||||
|
||||
async def test_challenge_expiration(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
token, _ = await service.issue_passkey_challenge(
|
||||
purpose='auth',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
ttl_seconds=0,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
|
||||
await service.consume_passkey_challenge(token, 'auth')
|
||||
|
||||
|
||||
class TestPasskeyOptionsGeneration:
|
||||
async def test_generate_registration_options(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
mock_user = Mock(spec=User)
|
||||
mock_user.uuid = 'acc-test-uuid'
|
||||
mock_user.user = '[email protected]'
|
||||
mock_user.status = AccountStatus.ACTIVE.value
|
||||
service.get_user_by_uuid = AsyncMock(return_value=mock_user)
|
||||
service.get_user_passkeys = AsyncMock(return_value=[])
|
||||
|
||||
options, token = await service.generate_passkey_registration_options(
|
||||
account_uuid='acc-test-uuid',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
rp_name='LangBot Test',
|
||||
)
|
||||
|
||||
assert isinstance(options, dict)
|
||||
assert options['rp']['name'] == 'LangBot Test'
|
||||
assert options['rp']['id'] == 'localhost'
|
||||
assert options['user']['name'] == '[email protected]'
|
||||
assert 'challenge' in options
|
||||
assert len(token) > 0
|
||||
|
||||
async def test_generate_authentication_options_discoverable(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
|
||||
options, token = await service.generate_passkey_authentication_options(
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
)
|
||||
|
||||
assert isinstance(options, dict)
|
||||
assert options['rpId'] == 'localhost'
|
||||
assert 'challenge' in options
|
||||
assert len(token) > 0
|
||||
@@ -0,0 +1,60 @@
|
||||
import importlib
|
||||
import importlib.util
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.context import RequestContext, WorkspaceContext, PrincipalContext, PrincipalType
|
||||
from langbot.pkg.api.http.authz import permissions_for_role
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_routes_user_token_permissions_and_strict_request():
|
||||
path = 'langbot.pkg.api.http.controller.groups.pipelines.migration'
|
||||
assert importlib.util.find_spec(path), 'manual migration routes missing'
|
||||
cls = importlib.import_module(path).PipelineMigrationRouterGroup
|
||||
from langbot.pkg.api.http.service.pipeline_migration import MigrationError
|
||||
|
||||
app = quart.Quart(__name__)
|
||||
router = cls(NS(persistence_mgr=NS()), app)
|
||||
router._authenticate_support_admin = AsyncMock(return_value=None)
|
||||
router._authenticate_account = AsyncMock(return_value=(NS(uuid='account'), '[email protected]'))
|
||||
viewer = RequestContext(
|
||||
'instance',
|
||||
1,
|
||||
'request',
|
||||
'user_token',
|
||||
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'),
|
||||
WorkspaceContext('workspace', 'membership', 'viewer', permissions_for_role('viewer')),
|
||||
)
|
||||
router._resolve_account_context = AsyncMock(return_value=viewer)
|
||||
await router.initialize()
|
||||
router.service = NS(
|
||||
preview=AsyncMock(return_value={'items': [], 'total': 0}), execute=AsyncMock(return_value={'task_id': 1})
|
||||
)
|
||||
client = app.test_client()
|
||||
base = '/api/v1/pipelines/_/migration'
|
||||
assert (await client.get(base + '/preview')).status_code == 401
|
||||
assert (await client.get(base + '/preview', headers={'X-API-Key': 'synthetic'})).status_code == 401
|
||||
headers = {'Authorization': 'Bearer synthetic'}
|
||||
assert (await client.get(base + '/preview', headers=headers)).status_code == 200
|
||||
assert (await client.post(base + '/execute', headers=headers, json={})).status_code == 403
|
||||
router.service.execute.assert_not_awaited()
|
||||
manager = RequestContext(
|
||||
'instance',
|
||||
1,
|
||||
'request',
|
||||
'user_token',
|
||||
viewer.principal,
|
||||
WorkspaceContext('workspace', 'membership', 'developer', permissions_for_role('developer')),
|
||||
)
|
||||
router._resolve_account_context.return_value = manager
|
||||
router.service.execute.side_effect = MigrationError('confirmation_required', 400)
|
||||
response = await client.post(base + '/execute', headers=headers, json={'confirmed': 'true'})
|
||||
assert response.status_code == 400
|
||||
assert 'confirmation_required' in await response.get_data(as_text=True)
|
||||
router.service.execute.side_effect = None
|
||||
response = await client.post(base + '/execute', headers=headers, json={'confirmed': True, 'items': []})
|
||||
assert (await response.get_json())['data']['task_id'] == 1
|
||||
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.provider.models import (
|
||||
EmbeddingModelsRouterGroup,
|
||||
LLMModelsRouterGroup,
|
||||
RerankModelsRouterGroup,
|
||||
)
|
||||
from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvidersRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.provider.query import resolve_include_secret
|
||||
from langbot.pkg.api.http.service.secrets import redact_secrets
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
RAW_PROVIDER = {
|
||||
'uuid': 'provider-test',
|
||||
'name': 'Test Provider',
|
||||
'api_keys': ['provider-secret'],
|
||||
}
|
||||
RAW_MODEL = {
|
||||
'uuid': 'model-test',
|
||||
'name': 'Test Model',
|
||||
'extra_args': {'headers': {'Authorization': 'Bearer model-secret'}},
|
||||
}
|
||||
|
||||
|
||||
def _access(role: str):
|
||||
return SimpleNamespace(
|
||||
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
|
||||
workspace=SimpleNamespace(uuid='workspace-test'),
|
||||
membership=SimpleNamespace(uuid='membership-test', role=role, projection_revision=1),
|
||||
)
|
||||
|
||||
|
||||
def _project(value: dict, include_secret: bool) -> dict:
|
||||
value = copy.deepcopy(value)
|
||||
return value if include_secret else redact_secrets(value)
|
||||
|
||||
|
||||
async def _create_client(role: str):
|
||||
application = SimpleNamespace()
|
||||
account = SimpleNamespace(uuid='account-test', user='[email protected]')
|
||||
application.user_service = SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account))
|
||||
application.apikey_service = SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None))
|
||||
application.workspace_collaboration_service = SimpleNamespace(
|
||||
resolve_account_workspace=AsyncMock(return_value=_access(role))
|
||||
)
|
||||
|
||||
async def get_providers(_context, *, include_secret=False):
|
||||
return [_project(RAW_PROVIDER, include_secret)]
|
||||
|
||||
async def get_provider(_context, _uuid, *, include_secret=False):
|
||||
return _project(RAW_PROVIDER, include_secret)
|
||||
|
||||
application.provider_service = SimpleNamespace(
|
||||
get_providers=AsyncMock(side_effect=get_providers),
|
||||
get_provider=AsyncMock(side_effect=get_provider),
|
||||
get_provider_model_counts=AsyncMock(
|
||||
return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}
|
||||
),
|
||||
)
|
||||
|
||||
def model_service(list_name: str, get_name: str):
|
||||
async def get_models(_context, *, include_secret=False):
|
||||
return [_project(RAW_MODEL, include_secret)]
|
||||
|
||||
async def get_model(_context, _uuid, *, include_secret=False):
|
||||
return _project(RAW_MODEL, include_secret)
|
||||
|
||||
return SimpleNamespace(
|
||||
**{
|
||||
list_name: AsyncMock(side_effect=get_models),
|
||||
get_name: AsyncMock(side_effect=get_model),
|
||||
}
|
||||
)
|
||||
|
||||
application.llm_model_service = model_service('get_llm_models', 'get_llm_model')
|
||||
application.embedding_models_service = model_service('get_embedding_models', 'get_embedding_model')
|
||||
application.rerank_models_service = model_service('get_rerank_models', 'get_rerank_model')
|
||||
|
||||
quart_app = quart.Quart(__name__)
|
||||
for router_type in (
|
||||
ModelProvidersRouterGroup,
|
||||
LLMModelsRouterGroup,
|
||||
EmbeddingModelsRouterGroup,
|
||||
RerankModelsRouterGroup,
|
||||
):
|
||||
await router_type(application, quart_app).initialize()
|
||||
return application, quart_app.test_client()
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
return {'Authorization': 'Bearer test-token'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('raw_value', 'permitted', 'expected', 'error'),
|
||||
[
|
||||
(None, True, True, None),
|
||||
(None, False, False, None),
|
||||
('false', True, False, None),
|
||||
('true', True, True, None),
|
||||
('true', False, False, None),
|
||||
('invalid', True, False, 'include_secret must be either true or false'),
|
||||
],
|
||||
)
|
||||
def test_resolve_include_secret(raw_value, permitted, expected, error):
|
||||
assert resolve_include_secret(raw_value, permitted=permitted) == (expected, error)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'endpoint',
|
||||
[
|
||||
'/api/v1/provider/providers',
|
||||
'/api/v1/provider/models/llm',
|
||||
'/api/v1/provider/models/embedding',
|
||||
'/api/v1/provider/models/rerank',
|
||||
],
|
||||
)
|
||||
async def test_default_preserves_secrets_and_explicit_false_redacts_high_permission_reads(endpoint):
|
||||
application, client = await _create_client('developer')
|
||||
|
||||
default_response = await client.get(endpoint, headers=_headers())
|
||||
false_response = await client.get(f'{endpoint}?include_secret=false', headers=_headers())
|
||||
|
||||
assert default_response.status_code == 200
|
||||
assert false_response.status_code == 200
|
||||
default_data = await default_response.get_json()
|
||||
false_data = await false_response.get_json()
|
||||
default_value = default_data['data'].get('providers', default_data['data'].get('models'))[0]
|
||||
false_value = false_data['data'].get('providers', false_data['data'].get('models'))[0]
|
||||
assert '***' not in str(default_value)
|
||||
assert '***' in str(false_value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'endpoint',
|
||||
[
|
||||
'/api/v1/provider/providers',
|
||||
'/api/v1/provider/models/llm',
|
||||
'/api/v1/provider/models/embedding',
|
||||
'/api/v1/provider/models/rerank',
|
||||
],
|
||||
)
|
||||
async def test_explicit_true_does_not_grant_low_permission_reads(endpoint):
|
||||
_application, client = await _create_client('viewer')
|
||||
|
||||
response = await client.get(f'{endpoint}?include_secret=true', headers=_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
value = data['data'].get('providers', data['data'].get('models'))[0]
|
||||
assert '***' in str(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'endpoint',
|
||||
[
|
||||
'/api/v1/provider/providers',
|
||||
'/api/v1/provider/providers/provider-test',
|
||||
'/api/v1/provider/models/llm',
|
||||
'/api/v1/provider/models/llm/model-test',
|
||||
'/api/v1/provider/models/embedding',
|
||||
'/api/v1/provider/models/embedding/model-test',
|
||||
'/api/v1/provider/models/rerank',
|
||||
'/api/v1/provider/models/rerank/model-test',
|
||||
],
|
||||
)
|
||||
async def test_invalid_include_secret_returns_bad_request(endpoint):
|
||||
_application, client = await _create_client('developer')
|
||||
|
||||
response = await client.get(f'{endpoint}?include_secret=maybe', headers=_headers())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert (await response.get_json())['msg'] == 'include_secret must be either true or false'
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Regression tests for recovery-key hardening (#2392).
|
||||
|
||||
Covers two attack surfaces reported in GHSA-4xcp-6758-rxqv:
|
||||
|
||||
1. ``genkeys.py`` generated ``system.recovery_key`` with only 24 bits of
|
||||
entropy (``secrets.token_hex(3)``), making the whole keyspace brute-forceable.
|
||||
2. ``POST /api/v1/user/reset-password`` (unauthenticated) checked its failure
|
||||
counter across ``await`` points, so concurrent guesses all passed the gate
|
||||
before any accounting happened; admission is now a synchronous fixed-window
|
||||
quota consumed at entry, plus constant-time key comparison.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups import user as user_module
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.core.stages.genkeys import GenKeysStage
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
STORED_KEY = 'ABCD2345'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_quota_state():
|
||||
"""Reset the module-level admission-quota state before each test."""
|
||||
user_module._reset_password_state['window_started_at'] = 0.0
|
||||
user_module._reset_password_state['attempts'] = 0
|
||||
yield
|
||||
user_module._reset_password_state['window_started_at'] = 0.0
|
||||
user_module._reset_password_state['attempts'] = 0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_sleep(monkeypatch):
|
||||
"""Neutralize the fixed 3s delay so tests run instantly."""
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# genkeys.py: recovery-key generation and compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_genkeys_ap(existing_key: str) -> SimpleNamespace:
|
||||
"""Build a minimal Application mock for GenKeysStage.
|
||||
|
||||
Mirrors the real boot order: no ``logger`` attribute is set because
|
||||
GenKeysStage runs before SetupLoggerStage.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'jwt': {'secret': 'jwt-secret'}, 'recovery_key': existing_key}},
|
||||
dump_config=AsyncMock(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_recovery_key_generation_is_short_and_unambiguous():
|
||||
"""Eight random base32 characters balance manual entry and online throttling."""
|
||||
ap = _make_genkeys_ap(existing_key='')
|
||||
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
key = ap.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
|
||||
assert ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
async def test_legacy_low_entropy_key_preserved_with_warning(caplog):
|
||||
"""A legacy 6-char key must keep working but emit a warning, without ap.logger."""
|
||||
ap = _make_genkeys_ap(existing_key='ABC123')
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
assert ap.instance_config.data['system']['recovery_key'] == 'ABC123'
|
||||
assert any('Low-entropy' in record.message for record in caplog.records)
|
||||
assert not ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing_key', ['ABC123', 'ABCD2345', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
|
||||
async def test_recovery_key_generation_preserves_existing_key(existing_key):
|
||||
"""An explicitly configured recovery key must not be regenerated on boot."""
|
||||
ap = _make_genkeys_ap(existing_key=existing_key)
|
||||
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
assert ap.instance_config.data['system']['recovery_key'] == existing_key
|
||||
assert not ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
async def test_generated_key_is_preserved_without_legacy_warning(caplog):
|
||||
"""A restart must not warn about or replace the new eight-character key."""
|
||||
ap = _make_genkeys_ap(existing_key='')
|
||||
await GenKeysStage().run(ap)
|
||||
key = ap.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
ap.instance_config.dump_config.reset_mock()
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
assert ap.instance_config.data['system']['recovery_key'] == key
|
||||
assert not caplog.records
|
||||
ap.instance_config.dump_config.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_eight_character_key_does_not_trigger_legacy_warning(caplog):
|
||||
ap = _make_genkeys_ap(existing_key='ABCD2345')
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/user/reset-password: admission quota + constant-time compare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_client(stored_key: str = STORED_KEY):
|
||||
"""Create a Quart test client with a mocked Application."""
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
user_obj = SimpleNamespace(uuid='user-uuid', user='[email protected]')
|
||||
reset_password = AsyncMock()
|
||||
get_user_by_email = AsyncMock(return_value=user_obj)
|
||||
|
||||
ap = SimpleNamespace(
|
||||
user_service=SimpleNamespace(
|
||||
is_initialized=AsyncMock(return_value=True),
|
||||
get_user_by_email=get_user_by_email,
|
||||
reset_password=reset_password,
|
||||
),
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'recovery_key': stored_key}},
|
||||
),
|
||||
)
|
||||
|
||||
router = UserRouterGroup(ap, quart_app)
|
||||
await router.initialize()
|
||||
|
||||
client = quart_app.test_client()
|
||||
return client, reset_password, get_user_by_email
|
||||
|
||||
|
||||
def _payload(key: str = STORED_KEY) -> dict:
|
||||
return {'user': '[email protected]', 'recovery_key': key, 'new_password': 'NewPass1!'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', [STORED_KEY, 'ABC123', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
|
||||
async def test_correct_key_resets_password(key):
|
||||
"""New, legacy and explicitly configured keys all remain usable verbatim."""
|
||||
client, reset_password, _ = await _create_client(stored_key=key)
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())['code'] == 0
|
||||
reset_password.assert_awaited_once_with('[email protected]', 'NewPass1!')
|
||||
|
||||
|
||||
async def test_wrong_key_rejected_without_reset():
|
||||
"""A wrong recovery key returns 403 and never touches the password."""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
|
||||
assert resp.status_code == 403
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_non_string_recovery_key_does_not_crash():
|
||||
"""Malformed recovery-key payloads must be rejected, not raise a 500.
|
||||
|
||||
Constant-time comparison via hmac.compare_digest on bytes requires the
|
||||
input to be a str; other JSON types must fail closed.
|
||||
"""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
resp = await client.post(
|
||||
'/api/v1/user/reset-password',
|
||||
json={'user': '[email protected]', 'recovery_key': 12345, 'new_password': 'NewPass1!'},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff'])
|
||||
async def test_non_ascii_recovery_key_does_not_crash(key):
|
||||
"""Non-ASCII keys must compare safely (encode-based constant-time compare)."""
|
||||
client, _, _ = await _create_client()
|
||||
|
||||
resp = await client.post(
|
||||
'/api/v1/user/reset-password',
|
||||
json={'user': '[email protected]', 'recovery_key': key, 'new_password': 'NewPass1!'},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_quota_exhausted_after_max_attempts():
|
||||
"""After MAX admitted attempts even a correct key must be rejected with 429 (#2392).
|
||||
|
||||
Every admission consumes quota regardless of outcome; the legacy endpoint
|
||||
accepted every guess independently, exhausting the 24-bit keyspace via bursts.
|
||||
"""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW):
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
assert resp.status_code == 403
|
||||
|
||||
# The very next request carries the CORRECT key but has no quota left.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 429
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_quota_rejects_before_touching_user_lookup():
|
||||
"""An exhausted quota must reject early, before the sleep and any service calls."""
|
||||
client, _, get_user_by_email = await _create_client()
|
||||
|
||||
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
|
||||
user_module._reset_password_state['window_started_at'] = time.monotonic()
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
|
||||
assert resp.status_code == 429
|
||||
get_user_by_email.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_window_rolls_over_and_admits_again():
|
||||
"""Once the fixed window elapses, the quota resets and a correct key works again."""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
|
||||
user_module._reset_password_state['window_started_at'] = time.monotonic() - user_module._RESET_WINDOW_SECONDS - 1
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
|
||||
assert resp.status_code == 200
|
||||
reset_password.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_success_does_not_restore_quota():
|
||||
"""A successful reset does NOT restore quota: brute-force budget survives wins (#2392).
|
||||
|
||||
The legacy clear-on-success let attackers interleave correct-looking states;
|
||||
success only proves knowledge of the key once, it must not refill attempts.
|
||||
"""
|
||||
client, _, _ = await _create_client()
|
||||
|
||||
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW - 1):
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Last slot is spent on the genuine reset.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Quota is exhausted; even a correct key waits for the next window.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
async def test_concurrent_burst_cannot_bypass_quota(monkeypatch):
|
||||
"""A 20-request burst yields exactly {403: 5, 429: 15} (#2392 regression).
|
||||
|
||||
The vulnerable version accounted failures after several awaits, letting all
|
||||
concurrent requests pass the gate ({403: 20}). Admission is now synchronous
|
||||
and await-free, so total admissions are capped regardless of scheduling.
|
||||
"""
|
||||
|
||||
# Swap the AsyncMock sleep for a real cooperative yield so tasks actually
|
||||
# interleave mid-handler like they do under production load.
|
||||
async def _yield_sleep(_seconds):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=_yield_sleep))
|
||||
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
responses = await asyncio.gather(
|
||||
*(client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) for _ in range(20))
|
||||
)
|
||||
|
||||
status_counts: dict[int, int] = {}
|
||||
for resp in responses:
|
||||
status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1
|
||||
|
||||
assert status_counts == {403: 5, 429: 15}
|
||||
reset_password.assert_not_awaited()
|
||||
Reference in New Issue
Block a user