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:
fdc310
2026-09-19 11:00:45 +08:00
357 changed files with 43727 additions and 6682 deletions
@@ -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()