feat(plugin): 建立可信来源准入与安装恢复 (#6462)

This commit is contained in:
InfinityPacer
2026-08-26 07:52:00 +08:00
committed by GitHub
parent 3272f72823
commit 71db425c07
58 changed files with 11471 additions and 1493 deletions
+1
View File
@@ -314,6 +314,7 @@ def configure_plugin_system_services():
if flag else []
),
frozen=lambda: False,
install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"),
))
from app.agent.skills.registry import SkillHelper
from app.agent.llm.gateway import register_llm_provider_runtime
+84 -14
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6706,
"edge_sha256": "dc1249bc5f0ae05ec11c680236cf4dd389258cf883fbed66ed57b53875f9ef0e",
"edge_count": 6767,
"edge_sha256": "659804b4d1c0f3ff4d96e8c9a059df0afb74c91f368a73188c740558114efa61",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -157,6 +157,8 @@
"app.adapters.system.plugin.package -> app.adapters",
"app.adapters.system.plugin.package -> app.adapters.external",
"app.adapters.system.plugin.package -> app.adapters.external.market",
"app.adapters.system.plugin.package -> app.adapters.system",
"app.adapters.system.plugin.package -> app.adapters.system.host",
"app.adapters.system.plugin.package -> app.runtime",
"app.adapters.system.plugin.package -> app.runtime.execution",
"app.adapters.system.plugin.package -> app.runtime.log",
@@ -590,10 +592,6 @@
"app.agent.tools.impl._plugin_tool_utils -> app.adapters",
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.external",
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.market",
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.server",
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.system",
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin",
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin.package",
"app.agent.tools.impl._plugin_tool_utils -> app.agent",
"app.agent.tools.impl._plugin_tool_utils -> app.agent.tools",
"app.agent.tools.impl._plugin_tool_utils -> app.agent.tools.base",
@@ -602,7 +600,7 @@
"app.agent.tools.impl._plugin_tool_utils -> app.application.configuration",
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin",
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.folders",
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.install",
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.gateway",
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.routes",
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.runtime",
"app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling",
@@ -2100,9 +2098,6 @@
"app.api.endpoints.plugin -> app.adapters.external",
"app.api.endpoints.plugin -> app.adapters.external.market",
"app.api.endpoints.plugin -> app.adapters.external.server",
"app.api.endpoints.plugin -> app.adapters.system",
"app.api.endpoints.plugin -> app.adapters.system.plugin",
"app.api.endpoints.plugin -> app.adapters.system.plugin.package",
"app.api.endpoints.plugin -> app.adapters.web",
"app.api.endpoints.plugin -> app.adapters.web.security",
"app.api.endpoints.plugin -> app.adapters.web.security.access",
@@ -2119,13 +2114,13 @@
"app.api.endpoints.plugin -> app.application.plugin",
"app.api.endpoints.plugin -> app.application.plugin.config",
"app.api.endpoints.plugin -> app.application.plugin.folders",
"app.api.endpoints.plugin -> app.application.plugin.install",
"app.api.endpoints.plugin -> app.application.plugin.gateway",
"app.api.endpoints.plugin -> app.application.plugin.routes",
"app.api.endpoints.plugin -> app.application.plugin.runtime",
"app.api.endpoints.plugin -> app.application.plugin.transaction",
"app.api.endpoints.plugin -> app.application.scheduling",
"app.api.endpoints.plugin -> app.runtime",
"app.api.endpoints.plugin -> app.runtime.cache",
"app.api.endpoints.plugin -> app.runtime.execution",
"app.api.endpoints.plugin -> app.runtime.extensions",
"app.api.endpoints.plugin -> app.runtime.extensions.plugin",
"app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts",
@@ -2747,6 +2742,11 @@
"app.application.notification -> app.schemas.types",
"app.application.outbox -> app.schemas",
"app.application.outbox -> app.schemas.types",
"app.application.plugin.admission -> app.application",
"app.application.plugin.admission -> app.application.plugin",
"app.application.plugin.admission -> app.application.plugin.identity",
"app.application.plugin.admission -> app.application.plugin.inventory",
"app.application.plugin.admission -> app.application.plugin.source",
"app.application.plugin.config -> app.schemas",
"app.application.plugin.config -> app.schemas.exception",
"app.application.plugin.folders -> app.application",
@@ -2755,16 +2755,52 @@
"app.application.plugin.folders -> app.runtime.log",
"app.application.plugin.folders -> app.schemas",
"app.application.plugin.folders -> app.schemas.types",
"app.application.plugin.gateway -> app.application",
"app.application.plugin.gateway -> app.application.plugin",
"app.application.plugin.gateway -> app.application.plugin.admission",
"app.application.plugin.gateway -> app.application.plugin.identity",
"app.application.plugin.gateway -> app.application.plugin.install",
"app.application.plugin.gateway -> app.application.plugin.inventory",
"app.application.plugin.gateway -> app.application.plugin.lifecycle",
"app.application.plugin.gateway -> app.application.plugin.source",
"app.application.plugin.identity_migration -> app.application",
"app.application.plugin.identity_migration -> app.application.plugin",
"app.application.plugin.identity_migration -> app.application.plugin.identity",
"app.application.plugin.identity_migration -> app.application.plugin.source",
"app.application.plugin.identity_migration -> app.runtime",
"app.application.plugin.identity_migration -> app.runtime.log",
"app.application.plugin.install -> app.application",
"app.application.plugin.install -> app.application.plugin",
"app.application.plugin.install -> app.application.plugin.lifecycle",
"app.application.plugin.install -> app.application.plugin.admission",
"app.application.plugin.install -> app.application.plugin.identity",
"app.application.plugin.install -> app.application.plugin.source",
"app.application.plugin.install -> app.application.plugin.transaction",
"app.application.plugin.install -> app.runtime",
"app.application.plugin.install -> app.runtime.execution",
"app.application.plugin.install -> app.runtime.log",
"app.application.plugin.install -> app.schemas",
"app.application.plugin.install -> app.schemas.exception",
"app.application.plugin.inventory -> app.application",
"app.application.plugin.inventory -> app.application.plugin",
"app.application.plugin.inventory -> app.application.plugin.identity",
"app.application.plugin.inventory -> app.application.plugin.source",
"app.application.plugin.recovery -> app.application",
"app.application.plugin.recovery -> app.application.plugin",
"app.application.plugin.recovery -> app.application.plugin.install",
"app.application.plugin.recovery -> app.application.plugin.transaction",
"app.application.plugin.recovery -> app.runtime",
"app.application.plugin.recovery -> app.runtime.log",
"app.application.plugin.runtime -> app.schemas",
"app.application.plugin.runtime -> app.schemas.types",
"app.application.plugin.source -> app.application",
"app.application.plugin.source -> app.application.plugin",
"app.application.plugin.source -> app.application.plugin.identity",
"app.application.plugin.source -> app.foundation",
"app.application.plugin.source -> app.foundation.version",
"app.application.plugin.transaction -> app.application",
"app.application.plugin.transaction -> app.application.database",
"app.application.plugin.transaction -> app.application.plugin",
"app.application.plugin.transaction -> app.application.plugin.identity",
"app.application.recognition -> app.application",
"app.application.recognition -> app.application.configuration",
"app.application.recognition -> app.schemas",
@@ -3637,6 +3673,14 @@
"app.db.adapters.pluginidentity -> app.db.oper",
"app.db.adapters.pluginidentity -> app.db.oper.pluginidentity",
"app.db.adapters.pluginidentity -> app.db.uow",
"app.db.adapters.plugininstallation -> app.application",
"app.db.adapters.plugininstallation -> app.application.plugin",
"app.db.adapters.plugininstallation -> app.application.plugin.identity",
"app.db.adapters.plugininstallation -> app.application.plugin.transaction",
"app.db.adapters.plugininstallation -> app.db",
"app.db.adapters.plugininstallation -> app.db.models",
"app.db.adapters.plugininstallation -> app.db.models.pluginidentity",
"app.db.adapters.plugininstallation -> app.db.models.plugininstallation",
"app.db.adapters.site -> app.db",
"app.db.adapters.site -> app.db.oper",
"app.db.adapters.site -> app.db.oper.site",
@@ -3727,6 +3771,8 @@
"app.db.models.plugindata -> app.db.base",
"app.db.models.pluginidentity -> app.db",
"app.db.models.pluginidentity -> app.db.base",
"app.db.models.plugininstallation -> app.db",
"app.db.models.plugininstallation -> app.db.base",
"app.db.models.site -> app.db",
"app.db.models.site -> app.db.base",
"app.db.models.siteicon -> app.db",
@@ -6344,6 +6390,7 @@
"app.startup.initializers.modules -> app.application.outbox",
"app.startup.initializers.modules -> app.application.plugin",
"app.startup.initializers.modules -> app.application.plugin.runtime",
"app.startup.initializers.modules -> app.application.plugin.transaction",
"app.startup.initializers.modules -> app.application.security",
"app.startup.initializers.modules -> app.application.security.auth",
"app.startup.initializers.modules -> app.application.security.passkeys",
@@ -6375,6 +6422,8 @@
"app.startup.initializers.modules -> app.db.adapters.chain",
"app.startup.initializers.modules -> app.db.adapters.download",
"app.startup.initializers.modules -> app.db.adapters.outbox",
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
"app.startup.initializers.modules -> app.db.adapters.plugininstallation",
"app.startup.initializers.modules -> app.db.adapters.site",
"app.startup.initializers.modules -> app.db.adapters.subscription",
"app.startup.initializers.modules -> app.db.adapters.transaction",
@@ -6446,11 +6495,22 @@
"app.startup.initializers.plugins -> app.adapters.system.plugin.manifest",
"app.startup.initializers.plugins -> app.adapters.system.plugin.package",
"app.startup.initializers.plugins -> app.application",
"app.startup.initializers.plugins -> app.application.commands",
"app.startup.initializers.plugins -> app.application.configuration",
"app.startup.initializers.plugins -> app.application.plugin",
"app.startup.initializers.plugins -> app.application.plugin.catalog",
"app.startup.initializers.plugins -> app.application.plugin.data",
"app.startup.initializers.plugins -> app.application.plugin.gateway",
"app.startup.initializers.plugins -> app.application.plugin.identity",
"app.startup.initializers.plugins -> app.application.plugin.identity_migration",
"app.startup.initializers.plugins -> app.application.plugin.install",
"app.startup.initializers.plugins -> app.application.plugin.inventory",
"app.startup.initializers.plugins -> app.application.plugin.lifecycle",
"app.startup.initializers.plugins -> app.application.plugin.recovery",
"app.startup.initializers.plugins -> app.application.plugin.routes",
"app.startup.initializers.plugins -> app.application.plugin.runtime",
"app.startup.initializers.plugins -> app.application.plugin.transaction",
"app.startup.initializers.plugins -> app.application.scheduling",
"app.startup.initializers.plugins -> app.application.site",
"app.startup.initializers.plugins -> app.db",
"app.startup.initializers.plugins -> app.db.oper",
@@ -6498,6 +6558,7 @@
"app.startup.lifecycle -> app.application",
"app.startup.lifecycle -> app.application.plugin",
"app.startup.lifecycle -> app.application.plugin.lifecycle",
"app.startup.lifecycle -> app.application.plugin.recovery",
"app.startup.lifecycle -> app.application.plugin.runtime",
"app.startup.lifecycle -> app.chain",
"app.startup.lifecycle -> app.chain.system",
@@ -6723,7 +6784,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 824,
"module_count": 833,
"modules": [
"app",
"app.adapters",
@@ -7014,15 +7075,22 @@
"app.application.notification",
"app.application.outbox",
"app.application.plugin",
"app.application.plugin.admission",
"app.application.plugin.catalog",
"app.application.plugin.config",
"app.application.plugin.data",
"app.application.plugin.folders",
"app.application.plugin.gateway",
"app.application.plugin.identity",
"app.application.plugin.identity_migration",
"app.application.plugin.install",
"app.application.plugin.inventory",
"app.application.plugin.lifecycle",
"app.application.plugin.recovery",
"app.application.plugin.routes",
"app.application.plugin.runtime",
"app.application.plugin.source",
"app.application.plugin.transaction",
"app.application.recognition",
"app.application.rss",
"app.application.rules",
@@ -7109,6 +7177,7 @@
"app.db.adapters.download",
"app.db.adapters.outbox",
"app.db.adapters.pluginidentity",
"app.db.adapters.plugininstallation",
"app.db.adapters.site",
"app.db.adapters.subscription",
"app.db.adapters.transaction",
@@ -7133,6 +7202,7 @@
"app.db.models.passkey",
"app.db.models.plugindata",
"app.db.models.pluginidentity",
"app.db.models.plugininstallation",
"app.db.models.site",
"app.db.models.siteicon",
"app.db.models.sitestatistic",
+131 -131
View File
@@ -1,41 +1,41 @@
{
"schema_version": 2,
"generated_at": "2026-08-24T23:06:26.288074+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
"python": "3.14.3",
"generated_at": "2026-08-25T23:30:44.529042+00:00",
"platform": "macOS-26.4.1-arm64-arm-64bit-Mach-O",
"python": "3.14.7",
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 364,
"max_ms": 904.069,
"median_ms": 898.164,
"min_ms": 896.39,
"loaded_app_module_count": 378,
"max_ms": 909.62,
"median_ms": 908.975,
"min_ms": 904.929,
"samples_ms": [
904.069,
896.39,
898.164
909.62,
904.929,
908.975
]
},
"app.factory": {
"loaded_app_module_count": 376,
"max_ms": 923.165,
"median_ms": 921.768,
"min_ms": 921.249,
"loaded_app_module_count": 390,
"max_ms": 952.709,
"median_ms": 934.785,
"min_ms": 916.888,
"samples_ms": [
921.768,
923.165,
921.249
952.709,
934.785,
916.888
]
},
"app.main": {
"loaded_app_module_count": 378,
"max_ms": 1069.392,
"median_ms": 1037.036,
"min_ms": 1027.603,
"loaded_app_module_count": 392,
"max_ms": 947.928,
"median_ms": 938.251,
"min_ms": 932.597,
"samples_ms": [
1069.392,
1037.036,
1027.603
938.251,
932.597,
947.928
]
}
},
@@ -47,56 +47,25 @@
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.645,
"full_lifespan_ms": 1.493,
"startup_ms": 0.581,
"full_lifespan_ms": 1.397,
"stage_ms": {
"后台任务登记器": 0.071,
"数据库准备": 0.043,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.023,
"路由": 0.022,
"模块服务": 0.024,
"插件备份恢复": 0.025,
"插件": 0.021,
"定时器": 0.025,
"监控器": 0.021,
"待处理整理回放": 0.027,
"命令服务": 0.024,
"工作流": 0.021,
"插件同步与启动收尾": 0.021
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.644,
"full_lifespan_ms": 1.462,
"stage_ms": {
"后台任务登记器": 0.076,
"数据库准备": 0.04,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.023,
"路由": 0.024,
"后台任务登记器": 0.062,
"数据库准备": 0.036,
"HTTP 基础能力": 0.032,
"领域依赖装配": 0.032,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.026,
"路由": 0.028,
"模块服务": 0.023,
"插件备份恢复": 0.024,
"插件": 0.02,
"定时器": 0.022,
"插件": 0.024,
"定时器": 0.025,
"监控器": 0.024,
"待处理整理回放": 0.02,
"待处理整理回放": 0.025,
"命令服务": 0.024,
"工作流": 0.024,
"插件同步与启动收尾": 0.023
"工作流": 0.025,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
@@ -109,25 +78,56 @@
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.637,
"full_lifespan_ms": 1.493,
"startup_ms": 0.596,
"full_lifespan_ms": 1.431,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.038,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.025,
"路由": 0.025,
"后台任务登记器": 0.062,
"数据库准备": 0.036,
"HTTP 基础能力": 0.033,
"领域依赖装配": 0.036,
"数据库引擎预热": 0.03,
"数据库连接预算": 0.027,
"路由": 0.029,
"模块服务": 0.025,
"插件备份恢复": 0.023,
"插件": 0.02,
"定时器": 0.024,
"监控器": 0.022,
"待处理整理回放": 0.022,
"插件": 0.024,
"定时器": 0.029,
"监控器": 0.024,
"待处理整理回放": 0.024,
"命令服务": 0.024,
"工作流": 0.02,
"插件同步与启动收尾": 0.023
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.6,
"full_lifespan_ms": 1.435,
"stage_ms": {
"后台任务登记器": 0.067,
"数据库准备": 0.04,
"HTTP 基础能力": 0.033,
"领域依赖装配": 0.031,
"数据库引擎预热": 0.031,
"数据库连接预算": 0.028,
"路由": 0.03,
"模块服务": 0.024,
"插件备份恢复": 0.026,
"插件": 0.025,
"定时器": 0.025,
"监控器": 0.025,
"待处理整理回放": 0.02,
"命令服务": 0.025,
"工作流": 0.023,
"插件同步与启动收尾": 0.024
},
"threads_before": 2,
"threads_started": 2,
@@ -138,8 +138,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.644,
"median_full_lifespan_ms": 1.493,
"median_startup_ms": 0.596,
"median_full_lifespan_ms": 1.431,
"enabled_component_count": 25,
"enabled_components": [
"后台任务登记器",
@@ -174,42 +174,18 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.478,
"full_lifespan_ms": 0.888,
"startup_ms": 0.427,
"full_lifespan_ms": 0.896,
"stage_ms": {
"后台任务登记器": 0.072,
"数据库准备": 0.042,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.033,
"数据库引擎预热": 0.027,
"数据库连接预算": 0.024,
"路由": 0.023,
"模块服务": 0.025,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.497,
"full_lifespan_ms": 0.913,
"stage_ms": {
"后台任务登记器": 0.078,
"后台任务登记器": 0.067,
"数据库准备": 0.038,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.032,
"数据库引擎预热": 0.027,
"HTTP 基础能力": 0.032,
"领域依赖装配": 0.033,
"数据库引擎预热": 0.029,
"数据库连接预算": 0.026,
"路由": 0.026,
"模块服务": 0.025,
"插件同步与启动收尾": 0.022
"路由": 0.03,
"模块服务": 0.03,
"插件同步与启动收尾": 0.027
},
"threads_before": 2,
"threads_started": 2,
@@ -222,18 +198,42 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.537,
"full_lifespan_ms": 0.962,
"startup_ms": 0.417,
"full_lifespan_ms": 0.832,
"stage_ms": {
"后台任务登记器": 0.084,
"数据库准备": 0.039,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.024,
"路由": 0.025,
"模块服务": 0.026,
"插件同步与启动收尾": 0.024
"后台任务登记器": 0.067,
"数据库准备": 0.035,
"HTTP 基础能力": 0.032,
"领域依赖装配": 0.031,
"数据库引擎预热": 0.032,
"数据库连接预算": 0.027,
"路由": 0.027,
"模块服务": 0.027,
"插件同步与启动收尾": 0.026
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.409,
"full_lifespan_ms": 0.822,
"stage_ms": {
"后台任务登记器": 0.067,
"数据库准备": 0.034,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.026,
"路由": 0.03,
"模块服务": 0.027,
"插件同步与启动收尾": 0.022
},
"threads_before": 2,
"threads_started": 2,
@@ -244,8 +244,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.497,
"median_full_lifespan_ms": 0.913,
"median_startup_ms": 0.417,
"median_full_lifespan_ms": 0.832,
"enabled_component_count": 13,
"enabled_components": [
"后台任务登记器",
+71 -42
View File
@@ -10,7 +10,7 @@ from app.agent.tools.impl._plugin_tool_utils import (
install_plugin_runtime,
uninstall_plugin_runtime,
)
from app.agent.tools.impl.install_plugin import InstallPluginTool
from app.agent.tools.impl.install_plugin import InstallPluginInput, InstallPluginTool
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
@@ -282,49 +282,84 @@ def test_install_plugin_installs_market_candidate() -> None:
assert payload["success"]
assert payload["plugin"]["id"] == "DemoPlugin"
install_runtime.assert_awaited_once_with(
"DemoPlugin", "https://example.com/market", force=False
"DemoPlugin",
None,
force=False,
explicit_source=False,
)
def test_install_plugin_runtime_reloads_in_threadpool() -> None:
"""
已存在插件刷新加载时会通过插件线程池执行重载。
"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"]
plugin_helper = MagicMock()
config_oper = MagicMock()
config_oper.get.return_value = ["DemoPlugin"]
calls = []
async def fake_run_agent_blocking(bucket, func, *args, **kwargs) -> None:
calls.append((bucket, func, args, kwargs))
return None
def test_install_plugin_reports_source_conflict_before_retry() -> None:
"""Agent 普通安装遇到多来源时返回候选,等待管理员明确选择。"""
tool = InstallPluginTool(session_id="session-1", user_id="10001")
candidate = _market_plugin("DemoPlugin", "Demo Plugin")
source_candidates = [
{
"plugin_id": "DemoPlugin",
"source_type": "official",
"source_key": "github:jxxghp/moviepilot-plugins",
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
"package_generation": "v3",
"plugin_version": "1.0.0",
},
{
"plugin_id": "DemoPlugin",
"source_type": "third_party",
"source_key": "github:example/plugins",
"repo_url": "https://github.com/example/plugins",
"package_generation": "v3",
"plugin_version": "2.0.0",
},
]
with (
patch(
"app.agent.tools.impl._plugin_tool_utils.get_configured_system_config",
return_value=config_oper,
"app.agent.tools.impl.install_plugin.load_market_plugins",
new=AsyncMock(return_value=[candidate]),
),
patch(
"app.agent.tools.impl._plugin_tool_utils.get_plugin_manager",
return_value=plugin_manager,
"app.agent.tools.impl.install_plugin.install_plugin_runtime",
new=AsyncMock(return_value=(False, "未安装插件存在多个在线来源", False)),
),
patch(
"app.agent.tools.impl._plugin_tool_utils.PluginHelper",
return_value=plugin_helper,
),
patch(
"app.agent.tools.impl._plugin_tool_utils.refresh_plugin_registrations",
) as refresh_registrations,
patch(
"app.agent.tools.impl._plugin_tool_utils.MoviePilotServerHelper.async_install_plugin_reg",
AsyncMock(return_value=True),
) as install_reg,
patch(
"app.agent.tools.base.run_agent_blocking",
side_effect=fake_run_agent_blocking,
"app.agent.tools.impl.install_plugin.inspect_plugin_sources",
new=AsyncMock(return_value={
"selection_status": "conflict",
"selection_reason": "未安装插件存在多个在线来源,不能静默选择",
"inventory_complete": True,
"candidates": source_candidates,
}),
),
):
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
payload = json.loads(result)
assert payload["success"] is False
assert payload["requires_explicit_source"] is True
assert payload["source_candidates"] == source_candidates
@pytest.mark.parametrize("repo_url", ["", " ", "local://DemoPlugin"])
def test_install_plugin_rejects_invalid_explicit_source(repo_url: str) -> None:
"""Agent 不能用空值或本地标识伪造管理员在线选源。"""
with pytest.raises(ValueError):
InstallPluginInput(plugin_id="DemoPlugin", repo_url=repo_url)
def test_install_plugin_runtime_uses_application_gateway() -> None:
"""Agent 安装入口只能转发到统一的应用层安装 Gateway。"""
gateway = MagicMock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(
success=True,
message="插件已存在,已刷新加载",
refreshed_only=True,
)
)
with patch(
"app.agent.tools.impl._plugin_tool_utils.get_plugin_install_service",
return_value=gateway,
):
success, message, refreshed_only = asyncio.run(
install_plugin_runtime(
@@ -337,18 +372,12 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None:
assert success
assert message == "插件已存在,已刷新加载"
assert refreshed_only
install_reg.assert_awaited_once_with(
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url="https://example.com/market",
force=False,
explicit_source=False,
)
assert len(calls) == 2
assert calls[0][0] == "plugin"
assert calls[0][2] == (plugin_manager.reload_plugin_tree, "DemoPlugin")
assert calls[0][3] == {}
assert calls[1][0] == "plugin"
assert calls[1][1] == refresh_registrations
assert calls[1][2] == ("DemoPlugin",)
assert calls[1][3] == {}
def test_uninstall_plugin_uninstalls_installed_candidate() -> None:
+11 -1
View File
@@ -45,6 +45,13 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
):
monkeypatch.setattr(lifecycle, name, MagicMock())
monkeypatch.setattr(lifecycle, "configure_plugin_services", MagicMock())
plugin_recovery = MagicMock()
plugin_recovery.replay = AsyncMock()
monkeypatch.setattr(
lifecycle,
"get_plugin_installation_recovery",
MagicMock(return_value=plugin_recovery),
)
monkeypatch.setattr(lifecycle, "init_modules", AsyncMock())
# 启动期的引擎预热与额度核算也要打桩。不打的话这些用例会走真实的引擎创建,在测试
@@ -442,6 +449,9 @@ def test_lifespan_configures_plugin_services_before_restore(monkeypatch):
shutdown_steps = _patch_lifespan(monkeypatch)
order = []
lifecycle.configure_plugin_services.side_effect = lambda: order.append("configure")
lifecycle.get_plugin_installation_recovery.return_value.replay.side_effect = (
lambda: order.append("replay")
)
lifecycle.SystemChain.return_value.restore_plugins.side_effect = (
lambda: order.append("restore")
)
@@ -452,7 +462,7 @@ def test_lifespan_configures_plugin_services_before_restore(monkeypatch):
asyncio.run(run_lifespan())
assert order == ["configure", "restore"]
assert order == ["configure", "replay", "restore"]
_assert_completed_once(shutdown_steps["close_http"])
+290
View File
@@ -0,0 +1,290 @@
"""插件市场候选库存读取测试。"""
import pytest
from app.application.plugin.identity import TrustedPluginSourceType
from app.application.plugin.inventory import PluginCandidateInventoryReader
from app.application.plugin.source import LocalCandidateReadStatus, MarketReadStatus
OFFICIAL_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins"
THIRD_PARTY_MARKET = "https://github.com/example/moviepilot-plugins"
def test_load_reads_each_market_in_v3_v2_v1_order_and_keeps_all_facts() -> None:
"""每个市场的三代索引都应有独立读取记录,且同 ID 候选不能被合并。"""
calls: list[tuple[str, str | None, bool]] = []
def loader(market: str, package_version: str | None, force: bool):
calls.append((market, package_version, force))
return {
"DemoPlugin": {
"version": f"{package_version or '1'}.0.0",
"v3": True,
},
}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[OFFICIAL_MARKET, THIRD_PARTY_MARKET],
force=True,
)
assert calls == [
(OFFICIAL_MARKET, "v3", True),
(OFFICIAL_MARKET, "v2", True),
(OFFICIAL_MARKET, None, True),
(THIRD_PARTY_MARKET, "v3", True),
(THIRD_PARTY_MARKET, "v2", True),
(THIRD_PARTY_MARKET, None, True),
]
assert inventory.complete
assert [(read.market, read.package_generation) for read in inventory.market_reads] == [
(OFFICIAL_MARKET, "v3"),
(OFFICIAL_MARKET, "v2"),
(OFFICIAL_MARKET, "v1"),
(THIRD_PARTY_MARKET, "v3"),
(THIRD_PARTY_MARKET, "v2"),
(THIRD_PARTY_MARKET, "v1"),
]
assert len(inventory.candidates_for("demoplugin")) == 6
def test_only_v3_compatible_entries_are_candidates() -> None:
"""V3 明确排除项以及 V1 未声明兼容项不能进入候选库存。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v3":
return {
"V3Plugin": {"version": "3.0.0"},
"ExcludedPlugin": {"version": "3.0.0", "v3": False},
}
if package_version == "v2":
return {
"SharedPlugin": {"version": "2.0.0"},
"ExcludedPlugin": {"version": "2.0.0", "v3": False},
}
return {
"DeclaredV3": {"version": "1.0.0", "v3": True},
"DeclaredV2": {"version": "1.0.0", "v2": True},
"Undeclared": {"version": "1.0.0"},
"ExcludedPlugin": {"version": "1.0.0", "v3": False, "v2": True},
}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
assert {
candidate.plugin_id
for candidate in inventory.online_candidates
} == {"V3Plugin", "SharedPlugin", "DeclaredV3", "DeclaredV2"}
assert not inventory.candidates_for("ExcludedPlugin")
assert not inventory.candidates_for("Undeclared")
def test_official_source_is_classified_and_public_candidate_uses_plugin_version() -> None:
"""官方仓库使用官方来源类型,候选公共字段与 Plugin schema 对齐。"""
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {"DemoPlugin": {"version": "3.1.0"}},
)
candidate = reader.load([OFFICIAL_MARKET]).online_candidates[0]
assert candidate.source_key == "github:jxxghp/moviepilot-plugins"
assert candidate.source_type is TrustedPluginSourceType.OFFICIAL
assert candidate.plugin_version == "3.1.0"
assert candidate.public_dict() == {
"plugin_id": "DemoPlugin",
"source_key": "github:jxxghp/moviepilot-plugins",
"source_type": "official",
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
"package_generation": "v3",
"plugin_version": "3.1.0",
}
def test_partial_generation_failure_blocks_tofu_but_keeps_successful_candidates() -> None:
"""某一代读取失败时保留其他代候选,但库存不能用于第三方 TOFU。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v2":
raise TimeoutError("timeout")
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
assert len(inventory.candidates_for("DemoPlugin")) == 2
assert not inventory.complete
assert not inventory.can_use_for_tofu
assert inventory.read_for(THIRD_PARTY_MARKET, "v2") is not None
assert inventory.read_for(THIRD_PARTY_MARKET, "v2").error
def test_absent_generation_is_complete_without_creating_candidates() -> None:
"""确定不存在的代际索引属于完整库存,不应被误判为网络失败。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v2":
return None
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
absent = inventory.read_for(THIRD_PARTY_MARKET, "v2")
assert absent is not None
assert absent.status is MarketReadStatus.ABSENT
assert absent.candidates == ()
assert inventory.complete
assert inventory.can_use_for_tofu
assert len(inventory.candidates_for("DemoPlugin")) == 2
def test_empty_index_is_present_and_complete() -> None:
"""真实存在但为空的索引与 absent 保持可观察差异。"""
inventory = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
).load([THIRD_PARTY_MARKET])
assert inventory.complete
assert all(
read.status is MarketReadStatus.PRESENT
for read in inventory.market_reads
)
assert inventory.online_candidates == ()
def test_loader_exception_blocks_tofu() -> None:
"""Adapter 读取失败时必须阻止唯一第三方来源 TOFU。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v2":
raise TimeoutError("timeout")
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
assert not inventory.complete
assert not inventory.can_use_for_tofu
assert inventory.read_for(THIRD_PARTY_MARKET, "v2").status is MarketReadStatus.FAILED
def test_local_scan_preserves_absent_present_and_failed_states() -> None:
"""本地仓库扫描不能把未配置、空扫描和异常读取混为一谈。"""
def market_loader(*_args):
return {}
absent = PluginCandidateInventoryReader(market_loader=market_loader).load(
[THIRD_PARTY_MARKET]
)
present = PluginCandidateInventoryReader(
market_loader=market_loader,
local_candidate_loader=lambda: {},
).load([THIRD_PARTY_MARKET])
def failed_loader():
raise OSError("local repository unavailable")
failed = PluginCandidateInventoryReader(
market_loader=market_loader,
local_candidate_loader=failed_loader,
).load([THIRD_PARTY_MARKET])
assert absent.local_read.status is LocalCandidateReadStatus.ABSENT
assert present.local_read.status is LocalCandidateReadStatus.PRESENT
assert present.local_read.candidates == ()
assert failed.local_read.status is LocalCandidateReadStatus.FAILED
assert failed.local_read.error == "local repository unavailable"
def test_local_candidates_never_expose_path_in_inventory_projection() -> None:
"""本地候选可参与库存,但公共投影永不携带本地仓库路径。"""
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
local_candidate_loader=lambda: {
"LocalPlugin": {
"version": "3.0.0",
"package_version": "v3",
"repo_url": "local://LocalPlugin?path=/private/local&version=v3",
"path": "/private/local/plugins/LocalPlugin",
"repo_path": "/private/local",
},
},
)
inventory = reader.load([OFFICIAL_MARKET])
public = inventory.public_dict()
assert inventory.local_candidates[0].plugin_id == "LocalPlugin"
assert public["local_candidates"] == [{
"plugin_id": "LocalPlugin",
"source_type": "local",
"package_generation": "v3",
"plugin_version": "3.0.0",
}]
assert "/private/local" not in str(public)
def test_invalid_local_candidate_does_not_abort_online_inventory() -> None:
"""本地索引中的坏代际条目应被跳过,不能丢失在线库存。"""
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {
"OnlinePlugin": {"version": "3.0.0"},
},
local_candidate_loader=lambda: {
"BrokenLocal": {
"version": "1.0.0",
"package_version": "v9",
},
},
)
inventory = reader.load([OFFICIAL_MARKET])
assert [candidate.plugin_id for candidate in inventory.online_candidates] == [
"OnlinePlugin",
"OnlinePlugin",
]
assert inventory.local_candidates == ()
def test_invalid_market_is_recorded_for_each_generation_without_network_call() -> None:
"""非法市场配置应形成三条失败事实,且不会调用市场读取端口。"""
calls: list[object] = []
def read(*_args):
calls.append(True)
return {}
inventory = PluginCandidateInventoryReader(market_loader=read).load(
["https://example.com/not-github"]
)
assert calls == []
assert len(inventory.market_reads) == 3
assert all(not read.succeeded for read in inventory.market_reads)
assert not inventory.complete
@pytest.mark.asyncio
async def test_async_loader_preserves_generation_facts() -> None:
"""异步读取端口与同步端口拥有相同的市场代际快照合同。"""
calls: list[str | None] = []
async def loader(_market: str, package_version: str | None, _force: bool):
calls.append(package_version)
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
async_market_loader=loader,
)
inventory = await reader.async_load([THIRD_PARTY_MARKET])
assert calls == ["v3", "v2", None]
assert inventory.complete
assert [read.package_generation for read in inventory.market_reads] == [
"v3", "v2", "v1"
]
@@ -0,0 +1,653 @@
"""插件包安装的外部调用边界测试。"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, call
import pytest
from fastapi import FastAPI
from pydantic import ValidationError
from app.adapters.external import market
from app.adapters.external.market import PluginHelper
from app.adapters.system.plugin.package import PluginPackageManager
from app.agent.tools.impl import _plugin_tool_utils
from app.api.endpoints import plugin as plugin_endpoint
from app.runtime.config import global_vars
from app.schemas.plugin import (
PluginSourceChangeRequest,
PluginSourceIdentity,
PluginSourceInstallRequest,
PluginSourceOptions,
)
from app.startup.initializers import plugins as plugins_initializer
REPO_URL = "https://github.com/example/moviepilot-plugins"
def test_package_manager_sync_preserves_external_install_contract() -> None:
"""同步包适配器必须调用包级入口,不能再次进入公开 Gateway。"""
helper = Mock()
helper._PluginHelper__install_package.return_value = (True, "installed")
manager = PluginPackageManager(helper=helper)
result = manager.install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
release_version="1.2.3",
force_install=False,
)
assert result == (True, "installed")
helper._PluginHelper__install_package.assert_called_once_with(
pid="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
release_version="1.2.3",
force_install=False,
)
helper.install.assert_not_called()
@pytest.mark.asyncio
async def test_package_manager_async_preserves_external_install_contract() -> None:
"""异步包适配器必须调用包级入口,不能再次进入公开 Gateway。"""
helper = Mock()
helper._PluginHelper__async_install_package = AsyncMock(return_value=(True, "installed"))
manager = PluginPackageManager(helper=helper)
result = await manager.async_install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
release_version="1.2.3",
force_install=False,
)
assert result == (True, "installed")
helper._PluginHelper__async_install_package.assert_awaited_once_with(
pid="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
release_version="1.2.3",
force_install=False,
)
helper.async_install.assert_not_called()
def test_external_sync_helper_rejects_until_gateway_is_configured(
monkeypatch,
) -> None:
"""外部同步入口在宿主未装配来源门禁时不得直接写入插件包。"""
helper = PluginHelper()
monkeypatch.setattr(
market,
"_plugin_install_gateway",
market._unconfigured_plugin_install_gateway,
)
success, message = helper.install(
"DemoPlugin",
REPO_URL,
"v3",
"1.2.3",
True,
)
assert success is False
assert message
@pytest.mark.asyncio
async def test_external_async_helper_rejects_until_gateway_is_configured(
monkeypatch,
) -> None:
"""外部异步入口在宿主未装配来源门禁时不得直接写入插件包。"""
helper = PluginHelper()
monkeypatch.setattr(
market,
"_async_plugin_install_gateway",
market._unconfigured_async_plugin_install_gateway,
)
success, message = await helper.async_install(
"DemoPlugin",
REPO_URL,
"v3",
"1.2.3",
True,
)
assert success is False
assert message
def test_external_sync_helper_uses_configured_gateway(monkeypatch) -> None:
"""外部同步调用必须把所有参数交给宿主统一安装用例。"""
gateway = Mock(return_value=(False, "source conflict"))
monkeypatch.setattr(market, "_plugin_install_gateway", gateway)
result = PluginHelper().install("DemoPlugin", REPO_URL, "v3", "1.2.3", True)
assert result == (False, "source conflict")
gateway.assert_called_once_with("DemoPlugin", REPO_URL, "v3", "1.2.3", True)
def test_sync_gateway_returns_failure_when_runtime_loop_is_unavailable(
monkeypatch,
) -> None:
"""主事件循环释放后,同步兼容入口应稳定返回失败结果。"""
gateway = Mock()
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", None)
result = plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
release_version="1.2.3",
force=False,
local_sync=False,
explicit_source=True,
)
assert result == (False, "插件安装服务当前不可用")
gateway.install.assert_not_called()
@pytest.mark.asyncio
async def test_external_async_helper_uses_configured_gateway(monkeypatch) -> None:
"""外部异步调用必须把所有参数交给宿主统一安装用例。"""
async def gateway(*args):
"""返回统一 Gateway 的结果。"""
seen.append(args)
return False, "source conflict"
seen = []
monkeypatch.setattr(market, "_async_plugin_install_gateway", gateway)
result = await PluginHelper().async_install(
"DemoPlugin",
REPO_URL,
"v3",
"1.2.3",
True,
)
assert result == (False, "source conflict")
assert seen == [("DemoPlugin", REPO_URL, "v3", "1.2.3", True)]
@pytest.mark.asyncio
async def test_external_async_helper_preserves_failure_tuple_on_gateway_error(
monkeypatch,
) -> None:
"""公开异步 Helper 在持久化等内部异常下仍返回兼容二元组。"""
gateway = Mock()
gateway.install = AsyncMock(side_effect=RuntimeError("persistence unavailable"))
async def install(*args):
"""按组合根的真实参数映射进入公开异步兼容包装层。"""
plugin_id, repo_url, package_version, release_version, force = args
return await plugins_initializer._run_plugin_install_async(
gateway,
plugin_id=plugin_id,
repo_url=repo_url,
package_version=package_version,
release_version=release_version,
force=force,
local_sync=False,
explicit_source=bool(repo_url),
)
monkeypatch.setattr(market, "_async_plugin_install_gateway", install)
result = await PluginHelper().async_install(
"DemoPlugin",
REPO_URL,
"v3",
"1.2.3",
True,
)
assert result == (False, "persistence unavailable")
@pytest.mark.asyncio
async def test_http_install_does_not_treat_repo_url_as_explicit_source(
monkeypatch,
) -> None:
"""旧 GET 安装入口不能把兼容参数误当成管理员明确选源。"""
gateway = Mock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(success=True, message="")
)
monkeypatch.setattr(
plugin_endpoint,
"get_plugin_install_service",
lambda: gateway,
)
result = await plugin_endpoint.install(
"DemoPlugin",
REPO_URL,
"1.2.3",
False,
None,
)
assert result.success is True
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url=None,
release_version="1.2.3",
force=False,
explicit_source=False,
)
@pytest.mark.asyncio
async def test_http_explicit_source_install_uses_explicit_gateway_mode(
monkeypatch,
) -> None:
"""专用来源安装入口必须把管理员选择传给统一 Gateway。"""
gateway = Mock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(success=True, message="")
)
monkeypatch.setattr(
plugin_endpoint,
"get_plugin_install_service",
lambda: gateway,
)
result = await plugin_endpoint.install_plugin_from_source(
"DemoPlugin",
PluginSourceInstallRequest(
repo_url=REPO_URL,
release_version="1.2.3",
force=True,
),
None,
)
assert result.success is True
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
release_version="1.2.3",
force=True,
explicit_source=True,
)
@pytest.mark.asyncio
async def test_http_source_change_requires_revision_and_explicit_gateway_mode(
monkeypatch,
) -> None:
"""管理员换源入口必须把目标仓库和精确 revision 交给统一 Gateway。"""
gateway = Mock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(success=True, message="")
)
monkeypatch.setattr(
plugin_endpoint,
"get_plugin_install_service",
lambda: gateway,
)
result = await plugin_endpoint.change_plugin_source(
"DemoPlugin",
PluginSourceChangeRequest(
repo_url=REPO_URL,
expected_revision=7,
release_version="1.2.3",
),
None,
)
assert result.success is True
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
release_version="1.2.3",
force=True,
explicit_source=True,
source_change=True,
expected_revision=7,
)
@pytest.mark.asyncio
async def test_http_source_identity_returns_current_cas_evidence(
monkeypatch,
) -> None:
"""来源查询只公开确认和显式换源所需的最小身份字段。"""
identity = SimpleNamespace(
plugin_id="DemoPlugin",
trusted_source_type=SimpleNamespace(value="official"),
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=SimpleNamespace(value="official_default"),
payload_source_type=SimpleNamespace(value="local"),
payload_source_key=None,
revision=7,
)
persistence = Mock()
persistence.get_identity = AsyncMock(return_value=identity)
monkeypatch.setattr(
plugin_endpoint,
"get_plugin_persistence",
lambda: persistence,
)
result = await plugin_endpoint.get_plugin_source_identity(
"DemoPlugin",
None,
)
assert result.success is True
assert isinstance(result.data, PluginSourceIdentity)
assert result.data.plugin_id == "DemoPlugin"
assert result.data.trusted_source_key == "github:jxxghp/moviepilot-plugins"
assert result.data.payload_source_type == "local"
assert result.data.revision == 7
def test_source_change_schema_rejects_invalid_revision_and_blank_repo() -> None:
"""显式换源请求在进入业务层前拒绝无来源或无效 revision。"""
with pytest.raises(ValidationError):
PluginSourceChangeRequest(repo_url=" ", expected_revision=1)
with pytest.raises(ValidationError):
PluginSourceChangeRequest(repo_url=REPO_URL, expected_revision=0)
with pytest.raises(ValidationError):
PluginSourceChangeRequest(
repo_url="local://DemoPlugin",
expected_revision=1,
)
@pytest.mark.asyncio
async def test_http_source_options_return_sanitized_candidates(monkeypatch) -> None:
"""来源候选接口保留在线选择信息,但本地候选不公开路径。"""
identity = SimpleNamespace(
plugin_id="DemoPlugin",
trusted_source_type=SimpleNamespace(value="official"),
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=SimpleNamespace(value="official_default"),
payload_source_type=SimpleNamespace(value="local"),
payload_source_key=None,
revision=7,
)
inspection = SimpleNamespace(
plugin_id="DemoPlugin",
inventory_complete=True,
identity=identity,
selection=SimpleNamespace(
status=SimpleNamespace(value="conflict"),
reason="未安装插件存在多个在线来源,不能静默选择",
),
online_candidates=(
SimpleNamespace(
public_dict=lambda: {
"plugin_id": "DemoPlugin",
"source_type": "official",
"source_key": "github:jxxghp/moviepilot-plugins",
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
"package_generation": "v3",
"plugin_version": "1.0.0",
}
),
),
local_candidate=SimpleNamespace(
public_dict=lambda: {
"plugin_id": "DemoPlugin",
"source_type": "local",
"package_generation": "v3",
"plugin_version": "2.0.0-dev",
}
),
)
gateway = Mock()
gateway.inspect_source = AsyncMock(return_value=inspection)
monkeypatch.setattr(
plugin_endpoint,
"get_plugin_install_service",
lambda: gateway,
)
result = await plugin_endpoint.get_plugin_source_options(
"DemoPlugin",
None,
)
assert result.success is True
assert isinstance(result.data, PluginSourceOptions)
assert result.data.identity is not None
assert result.data.identity.revision == 7
assert [candidate.source_type for candidate in result.data.candidates] == [
"official",
"local",
]
assert result.data.candidates[1].repo_url is None
assert "/private/" not in result.model_dump_json()
def test_source_api_openapi_uses_structured_contracts() -> None:
"""来源查询、初始选源和换源 API 必须公开稳定结构模型。"""
app = FastAPI()
app.include_router(plugin_endpoint.router, prefix="/api/v1/plugin")
paths = app.openapi()["paths"]
change_operation = paths["/api/v1/plugin/source/{plugin_id}"]["post"]
install_operation = paths["/api/v1/plugin/source/{plugin_id}/install"]["post"]
options_operation = paths["/api/v1/plugin/source/{plugin_id}/options"]["get"]
change_schema = change_operation["requestBody"]["content"]["application/json"]["schema"]
install_schema = install_operation["requestBody"]["content"]["application/json"]["schema"]
options_schema = options_operation["responses"]["200"]["content"]["application/json"]["schema"]
assert change_schema["$ref"].endswith("/PluginSourceChangeRequest")
assert install_schema["$ref"].endswith("/PluginSourceInstallRequest")
assert options_schema["$ref"].endswith("/Response_PluginSourceOptions_")
@pytest.mark.asyncio
async def test_agent_install_uses_application_gateway(
monkeypatch,
) -> None:
"""Agent 安装入口只能转发到唯一 Application Gateway。"""
gateway = Mock()
gateway.install = AsyncMock(
return_value=SimpleNamespace(
success=True,
message="installed",
refreshed_only=False,
)
)
monkeypatch.setattr(
_plugin_tool_utils,
"get_plugin_install_service",
lambda: gateway,
)
result = await _plugin_tool_utils.install_plugin_runtime(
"DemoPlugin",
REPO_URL,
force=False,
)
assert result == (True, "installed", False)
gateway.install.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
force=False,
explicit_source=False,
)
def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> None:
"""启动组合根必须向 Application 与公开 Helper 发布同一 Gateway。"""
helper = Mock()
gateway_calls = []
application_calls = []
gateway = Mock()
sync_runner = Mock(return_value=(True, "installed"))
async_runner = AsyncMock(return_value=(True, "installed"))
monkeypatch.setattr(plugins_initializer, "PluginHelper", lambda: helper)
monkeypatch.setattr(
plugins_initializer,
"PluginMarketClient",
lambda _helper: Mock(),
)
monkeypatch.setattr(
plugins_initializer,
"PluginPackageManager",
lambda _helper: Mock(),
)
monkeypatch.setattr(
plugins_initializer,
"PluginCandidateInventoryReader",
lambda **_kwargs: Mock(),
)
monkeypatch.setattr(
plugins_initializer,
"PluginInstallCommand",
lambda **_kwargs: Mock(),
)
monkeypatch.setattr(
plugins_initializer,
"PluginInstallGateway",
lambda **_kwargs: gateway,
)
monkeypatch.setattr(
plugins_initializer,
"PluginInstallationRecoveryService",
lambda **_kwargs: Mock(),
)
monkeypatch.setattr(
plugins_initializer,
"PluginIdentityMigrationService",
lambda **_kwargs: Mock(),
)
monkeypatch.setattr(plugins_initializer, "get_plugin_manager", Mock())
monkeypatch.setattr(plugins_initializer, "get_plugin_persistence", Mock())
monkeypatch.setattr(
plugins_initializer,
"configure_plugin_install_service",
application_calls.append,
)
monkeypatch.setattr(
plugins_initializer,
"configure_plugin_installation_recovery",
Mock(),
)
monkeypatch.setattr(
plugins_initializer,
"configure_plugin_identity_migration",
Mock(),
)
monkeypatch.setattr(
plugins_initializer,
"_run_plugin_install_sync",
sync_runner,
)
monkeypatch.setattr(
plugins_initializer,
"_run_plugin_install_async",
async_runner,
)
monkeypatch.setattr(
plugins_initializer,
"PluginDependencyInstaller",
lambda *_args, **_kwargs: Mock(),
)
for name in (
"configure_plugin_legacy_import_services",
"configure_plugin_resource_import_preparer",
"configure_site_auth_level_provider",
"configure_installed_plugins_provider",
"configure_plugin_catalog_factory",
"configure_plugin_route_refresher",
"configure_plugin_system",
"configure_plugin_storage",
):
monkeypatch.setattr(plugins_initializer, name, Mock())
def configure_gateway(**kwargs) -> None:
"""记录组合根提供给外部 Helper 的同步/异步端口。"""
gateway_calls.append(kwargs)
monkeypatch.setattr(
plugins_initializer,
"configure_plugin_install_gateway",
configure_gateway,
raising=False,
)
plugins_initializer.configure_plugin_services()
assert application_calls == [gateway]
assert len(gateway_calls) == 1
assert callable(gateway_calls[0]["install"])
assert callable(gateway_calls[0]["async_install"])
assert gateway_calls[0]["install"](
"DemoPlugin",
REPO_URL,
"v3",
"1.2.3",
False,
) == (True, "installed")
assert asyncio.run(
gateway_calls[0]["async_install"](
"DemoPlugin",
REPO_URL,
"v3",
"1.2.3",
False,
)
) == (True, "installed")
local_repo_url = "local://DemoPlugin?path=/private/plugins&version=v3"
assert gateway_calls[0]["install"](
"DemoPlugin",
local_repo_url,
"v3",
None,
True,
) == (True, "installed")
assert asyncio.run(
gateway_calls[0]["async_install"](
"DemoPlugin",
local_repo_url,
"v3",
None,
True,
)
) == (True, "installed")
online_expected = {
"plugin_id": "DemoPlugin",
"repo_url": "",
"package_version": "v3",
"release_version": "1.2.3",
"force": False,
"local_sync": False,
"explicit_source": False,
}
local_expected = {
"plugin_id": "DemoPlugin",
"repo_url": local_repo_url,
"package_version": "v3",
"release_version": None,
"force": True,
"local_sync": True,
"explicit_source": True,
}
assert sync_runner.call_args_list == [
call(gateway, **online_expected),
call(gateway, **local_expected),
]
assert async_runner.await_args_list == [
call(gateway, **online_expected),
call(gateway, **local_expected),
]
+29 -34
View File
@@ -141,7 +141,6 @@ def _patch_sync_remote_install(helper, monkeypatch, meta: dict,
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove"))
monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, ""))
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: calls.append("refresh"))
def fake_release(_pid, _user_repo, _release_tag):
calls.append("release")
@@ -2080,11 +2079,11 @@ demo = { index = "private" }
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "release", "refresh"] == calls
assert ["remove", "release"] == calls
def test_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch):
"""
@@ -2104,11 +2103,11 @@ demo = { index = "private" }
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "release", "remove", "filelist", "refresh"] == calls
assert ["remove", "release", "remove", "filelist"] == calls
def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
"""
@@ -2128,7 +2127,7 @@ demo = { index = "private" }
(False, "获取文件列表失败"),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success
assert "获取文件列表失败" == message
@@ -2152,11 +2151,11 @@ demo = { index = "private" }
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "filelist", "refresh"] == calls
assert ["remove", "filelist"] == calls
def test_install_rejects_release_without_version(self, monkeypatch):
"""
@@ -2175,7 +2174,7 @@ demo = { index = "private" }
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success
assert f"未在插件清单中找到 {PLUGIN_ID} 的版本号" in message
@@ -2199,7 +2198,7 @@ demo = { index = "private" }
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0"))
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success
assert "MoviePilot 版本 >=9.0.0" in message
@@ -2228,7 +2227,7 @@ demo = { index = "private" }
lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}],
)
success, message = helper.install(
success, message = helper._PluginHelper__install_package(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.3", force_install=True
)
@@ -2260,7 +2259,7 @@ demo = { index = "private" }
lambda *_args: [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}],
)
success, message = helper.install(
success, message = helper._PluginHelper__install_package(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
@@ -2290,7 +2289,7 @@ demo = { index = "private" }
lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}],
)
success, message = helper.install(
success, message = helper._PluginHelper__install_package(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
@@ -2307,7 +2306,7 @@ demo = { index = "private" }
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
success, message = PluginHelper().install("", REPO_URL)
success, message = PluginHelper()._PluginHelper__install_package("", REPO_URL)
assert not success
assert "参数错误" == message
@@ -2321,7 +2320,7 @@ demo = { index = "private" }
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
success, message = PluginHelper().install(PLUGIN_ID, "not-a-repo-url")
success, message = PluginHelper()._PluginHelper__install_package(PLUGIN_ID, "not-a-repo-url")
assert not success
assert "不支持的插件仓库地址格式" == message
@@ -2338,7 +2337,7 @@ demo = { index = "private" }
helper = PluginHelper()
monkeypatch.setattr(helper, "get_plugin_package_version", lambda *_args: None)
success, message = helper.install(PLUGIN_ID, REPO_URL)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL)
assert not success
assert f"{PLUGIN_ID} 没有找到适用于当前版本的插件" == message
@@ -2359,10 +2358,9 @@ demo = { index = "private" }
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, ""))
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_sync", lambda *_args: (True, ""))
success, message = helper.install(PLUGIN_ID, REPO_URL, force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, force_install=True)
assert success
assert "" == message
@@ -2399,9 +2397,8 @@ demo = { index = "private" }
},
)
monkeypatch.setattr("app.adapters.external.market.PLUGIN_DIR", runtime_root)
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: True)
success, message = helper.install(
success, message = helper._PluginHelper__install_package(
PLUGIN_ID,
helper.make_local_repo_url(PLUGIN_ID, repo_path, "v2"),
force_install=True,
@@ -2431,11 +2428,11 @@ demo = { index = "private" }
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "release", "remove", "filelist", "refresh"] == calls
assert ["remove", "release", "remove", "filelist"] == calls
def test_async_install_uses_release_package_when_asset_is_available(self, monkeypatch):
"""
@@ -2455,13 +2452,12 @@ demo = { index = "private" }
)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert success
assert "" == message
assert calls[:2] == ["remove", "release"]
assert calls[2][0] == "to_thread"
assert calls == ["remove", "release"]
def test_async_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch):
"""
@@ -2482,13 +2478,12 @@ demo = { index = "private" }
)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert success
assert "" == message
assert calls[:4] == ["remove", "release", "remove", "filelist"]
assert calls[4][0] == "to_thread"
assert calls == ["remove", "release", "remove", "filelist"]
def test_async_install_old_release_version_uses_release_asset_without_filelist_fallback(self, monkeypatch):
"""
@@ -2515,7 +2510,7 @@ demo = { index = "private" }
monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases)
success, message = asyncio.run(
helper.async_install(
helper._PluginHelper__async_install_package(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
)
@@ -2547,7 +2542,7 @@ demo = { index = "private" }
monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases)
success, message = asyncio.run(
helper.async_install(
helper._PluginHelper__async_install_package(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
)
@@ -2575,7 +2570,7 @@ demo = { index = "private" }
)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert not success
@@ -2608,7 +2603,7 @@ demo = { index = "private" }
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert success
@@ -2641,7 +2636,7 @@ demo = { index = "private" }
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert success
@@ -3521,7 +3516,7 @@ demo = { index = "private" }
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
success, message = PluginHelper().install("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins")
success, message = PluginHelper()._PluginHelper__install_package("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins")
assert not success
assert "本地插件来源与插件ID不匹配" == message
@@ -0,0 +1,545 @@
"""存量插件身份启动迁移的来源和顺序合同测试。"""
from __future__ import annotations
import asyncio
from contextlib import nullcontext
from dataclasses import replace
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginIdentityConflictError,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.identity_migration import PluginIdentityMigrationService
from app.application.plugin.source import (
CandidateInventory,
LocalCandidateRead,
MarketRead,
PluginMarketCandidate,
)
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
from app.startup.initializers import plugins as plugins_initializer
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
OFFICIAL_REPO = "https://github.com/jxxghp/MoviePilot-Plugins"
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
THIRD_PARTY_REPO = "https://github.com/example/MoviePilot-Plugins"
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
class _Persistence:
"""提供可观察 CAS 竞争的内存迁移持久化端口。"""
def __init__(self, identities: tuple[PluginIdentity, ...] = ()) -> None:
self.identities = {
identity.normalized_plugin_id: identity for identity in identities
}
self.fail_create = False
self.fail_bind = False
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
"""按规范物理 ID 返回当前身份。"""
return self.identities.get(plugin_id.lower())
async def migrate_identity(
self,
identity: PluginIdentity,
*,
expected_revision: int | None,
) -> PluginIdentity:
"""模拟首次身份 CAS。"""
assert expected_revision is None
if self.fail_create or identity.normalized_plugin_id in self.identities:
raise PluginIdentityConflictError("create conflict")
self.identities[identity.normalized_plugin_id] = identity
return identity
async def bind_online_identity(
self,
identity: PluginIdentity,
*,
expected_revision: int,
) -> PluginIdentity:
"""模拟未绑定身份的 revision CAS。"""
current = self.identities.get(identity.normalized_plugin_id)
if (
self.fail_bind
or current is None
or current.revision != expected_revision
):
raise PluginIdentityConflictError("bind conflict")
self.identities[identity.normalized_plugin_id] = identity
return identity
def _candidate(
plugin_id: str,
*,
source_type: TrustedPluginSourceType,
source_key: str,
repo_url: str,
) -> PluginMarketCandidate:
"""构造一个 V3 在线候选。"""
return PluginMarketCandidate(
plugin_id=plugin_id,
source_key=source_key,
source_type=source_type,
repo_url=repo_url,
package_generation="v3",
plugin_version="1.0.0",
)
def _inventory(
*candidates: PluginMarketCandidate,
failed_market: bool = False,
) -> CandidateInventory:
"""构造完整或部分失败的市场库存。"""
reads = [
MarketRead.present(
OFFICIAL_REPO,
candidates,
package_generation="v3",
)
]
expected_markets = [OFFICIAL_REPO]
if failed_market:
failed_repo = "https://github.com/unavailable/MoviePilot-Plugins"
reads.append(
MarketRead.failure(
failed_repo,
"unavailable",
package_generation="v3",
)
)
expected_markets.append(failed_repo)
return CandidateInventory(
market_reads=tuple(reads),
expected_markets=tuple(expected_markets),
expected_generations=("v3",),
local_read=LocalCandidateRead.absent(),
)
def _legacy(plugin_id: str = "DemoPlugin") -> PluginIdentity:
"""构造尚未绑定在线来源的存量身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
declared_version=None,
package_generation=None,
system_version=None,
supports_v3=None,
supports_v3t=None,
payload_receipt=None,
revision=1,
created_at=NOW,
updated_at=NOW,
bound_at=None,
payload_applied_at=None,
)
def _service(
persistence: _Persistence,
inventory: CandidateInventory,
installed: list[str],
*,
virtual: set[str] | None = None,
) -> PluginIdentityMigrationService:
"""装配固定库存和安装清单的迁移服务。"""
virtual_ids = virtual or set()
return PluginIdentityMigrationService(
persistence=persistence,
inventory=AsyncMock(return_value=inventory),
installed_plugins=lambda: installed,
is_virtual_instance=lambda plugin_id: plugin_id in virtual_ids,
clock=lambda: NOW,
)
@pytest.mark.asyncio
async def test_migration_binds_official_and_unique_third_party_sources() -> None:
"""官方默认和完整库存中的唯一第三方来源都可建立更新绑定。"""
persistence = _Persistence()
inventory = _inventory(
_candidate(
"OfficialPlugin",
source_type=TrustedPluginSourceType.OFFICIAL,
source_key=OFFICIAL_SOURCE,
repo_url=OFFICIAL_REPO,
),
_candidate(
"ThirdPartyPlugin",
source_type=TrustedPluginSourceType.THIRD_PARTY,
source_key=THIRD_PARTY_SOURCE,
repo_url=THIRD_PARTY_REPO,
),
)
result = await _service(
persistence,
inventory,
["OfficialPlugin", "ThirdPartyPlugin", "VirtualPlugin"],
virtual={"VirtualPlugin"},
).migrate()
assert result.created == 2
assert result.bound == 2
assert result.unbound == 0
assert result.skipped == 1
official = persistence.identities["officialplugin"]
third_party = persistence.identities["thirdpartyplugin"]
assert official.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
assert official.payload_source_type is PluginPayloadSourceType.UNKNOWN
assert third_party.binding_basis is PluginBindingBasis.TOFU
assert third_party.payload_source_type is PluginPayloadSourceType.UNKNOWN
@pytest.mark.asyncio
@pytest.mark.parametrize("failed_market", (False, True))
async def test_migration_keeps_ambiguous_or_incomplete_third_party_unbound(
failed_market: bool,
) -> None:
"""多来源或库存读取失败时不得猜测第三方更新来源。"""
candidates = (
_candidate(
"DemoPlugin",
source_type=TrustedPluginSourceType.THIRD_PARTY,
source_key=THIRD_PARTY_SOURCE,
repo_url=THIRD_PARTY_REPO,
),
)
if not failed_market:
candidates += (
_candidate(
"DemoPlugin",
source_type=TrustedPluginSourceType.THIRD_PARTY,
source_key="github:second/moviepilot-plugins",
repo_url="https://github.com/second/MoviePilot-Plugins",
),
)
persistence = _Persistence()
result = await _service(
persistence,
_inventory(*candidates, failed_market=failed_market),
["DemoPlugin"],
).migrate()
assert result.created == 1
assert result.bound == 0
assert result.unbound == 1
identity = persistence.identities["demoplugin"]
assert identity.binding_basis is PluginBindingBasis.LEGACY_UNBOUND
assert identity.trusted_source_key is None
@pytest.mark.asyncio
async def test_migration_later_binds_legacy_identity_without_rewriting_payload() -> None:
"""后续市场证据充分时只升级可信来源,不改写未知存量载荷。"""
legacy = _legacy("DemoPlugin")
persistence = _Persistence((legacy,))
inventory = _inventory(
_candidate(
"demoplugin",
source_type=TrustedPluginSourceType.THIRD_PARTY,
source_key=THIRD_PARTY_SOURCE,
repo_url=THIRD_PARTY_REPO,
)
)
result = await _service(
persistence,
inventory,
["demoplugin"],
).migrate()
assert result.bound == 1
identity = persistence.identities["demoplugin"]
assert identity.plugin_id == "DemoPlugin"
assert identity.created_at == legacy.created_at
assert identity.revision == 2
assert identity.binding_basis is PluginBindingBasis.TOFU
assert identity.payload_source_type is PluginPayloadSourceType.UNKNOWN
@pytest.mark.asyncio
async def test_migration_accepts_concurrent_create_winner() -> None:
"""首次身份 CAS 竞争已有赢家时,迁移跳过而不覆盖最终身份。"""
persistence = _Persistence()
inventory = _inventory(
_candidate(
"DemoPlugin",
source_type=TrustedPluginSourceType.OFFICIAL,
source_key=OFFICIAL_SOURCE,
repo_url=OFFICIAL_REPO,
)
)
async def create_conflict(
identity: PluginIdentity,
*,
expected_revision: int | None,
) -> PluginIdentity:
assert expected_revision is None
persistence.identities[identity.normalized_plugin_id] = identity
raise PluginIdentityConflictError("concurrent create")
persistence.migrate_identity = create_conflict # type: ignore[method-assign]
result = await _service(persistence, inventory, ["DemoPlugin"]).migrate()
assert result.created == 0
assert result.skipped == 1
assert persistence.identities["demoplugin"].trusted_source_key == OFFICIAL_SOURCE
@pytest.mark.asyncio
async def test_migration_accepts_concurrent_bind_winner() -> None:
"""存量绑定 CAS 已由其他执行者推进时,迁移保留赢家并幂等结束。"""
persistence = _Persistence((_legacy("DemoPlugin"),))
inventory = _inventory(
_candidate(
"DemoPlugin",
source_type=TrustedPluginSourceType.THIRD_PARTY,
source_key=THIRD_PARTY_SOURCE,
repo_url=THIRD_PARTY_REPO,
)
)
async def bind_conflict(
identity: PluginIdentity,
*,
expected_revision: int,
) -> PluginIdentity:
assert expected_revision == 1
persistence.identities[identity.normalized_plugin_id] = identity
raise PluginIdentityConflictError("concurrent bind")
persistence.bind_online_identity = bind_conflict # type: ignore[method-assign]
result = await _service(persistence, inventory, ["DemoPlugin"]).migrate()
assert result.bound == 0
assert result.skipped == 1
winner = persistence.identities["demoplugin"]
assert winner.revision == 2
assert winner.trusted_source_key == THIRD_PARTY_SOURCE
@pytest.mark.asyncio
async def test_migration_does_not_replace_existing_bound_or_local_identity() -> None:
"""重复启动不得覆盖已绑定在线来源或本地开发身份。"""
bound = replace(
_legacy("BoundPlugin"),
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=OFFICIAL_SOURCE,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
bound_at=NOW,
)
local = replace(
_legacy("LocalPlugin"),
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="1.0.0-dev",
package_generation="v3",
payload_receipt="sha256:" + "1" * 64,
payload_applied_at=NOW,
)
persistence = _Persistence((bound, local))
inventory = _inventory(
_candidate(
"BoundPlugin",
source_type=TrustedPluginSourceType.OFFICIAL,
source_key=OFFICIAL_SOURCE,
repo_url=OFFICIAL_REPO,
),
_candidate(
"LocalPlugin",
source_type=TrustedPluginSourceType.OFFICIAL,
source_key=OFFICIAL_SOURCE,
repo_url=OFFICIAL_REPO,
),
)
result = await _service(
persistence,
inventory,
["BoundPlugin", "LocalPlugin", "BOUNDPLUGIN"],
).migrate()
assert result.created == 0
assert result.bound == 0
assert result.skipped == 3
assert persistence.identities["boundplugin"] == bound
assert persistence.identities["localplugin"] == local
@pytest.mark.asyncio
async def test_collect_online_restore_plugins_requires_trust_and_local_payload() -> None:
"""仅在线可信来源仍绑定的本地载荷需要进入启动恢复候选。"""
trusted_local = replace(
_legacy("TrustedLocal"),
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=OFFICIAL_SOURCE,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="9.9.10",
package_generation="v3",
payload_receipt="sha256:" + "2" * 64,
bound_at=NOW,
payload_applied_at=NOW,
)
local_only = replace(
_legacy("LocalOnly"),
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="1.0.0-dev",
package_generation="v3",
payload_receipt="sha256:" + "3" * 64,
payload_applied_at=NOW,
)
online = replace(
_legacy("OnlinePayload"),
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=OFFICIAL_SOURCE,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=OFFICIAL_SOURCE,
declared_version="1.2.0",
package_generation="v3",
payload_receipt="sha256:" + "4" * 64,
bound_at=NOW,
payload_applied_at=NOW,
)
persistence = _Persistence((trusted_local, local_only, online))
result = await plugins_initializer._collect_online_restore_plugins(
persistence,
["TrustedLocal", "TRUSTEDLOCAL", "LocalOnly", "OnlinePayload", "bad-id"],
)
assert result == {"trustedlocal"}
@pytest.mark.asyncio
async def test_sync_runs_identity_migration_before_automatic_install(
monkeypatch,
) -> None:
"""启动自动同步必须在存量来源迁移完成后才能读取和替换载荷。"""
order: list[str] = []
manager = MagicMock()
manager.mutation.return_value = nullcontext()
def sync(_token, *, online_restore_plugins):
order.append("sync")
assert online_restore_plugins == {"demoplugin"}
return []
manager.sync.side_effect = sync
manager.async_install_plugin_missing_dependencies_with_status = AsyncMock(
return_value=PluginDependencyInstallResult(missing=[], success=True)
)
manager.get_plugin_runtime_statuses.return_value = {}
manager.classify_plugins.return_value = MagicMock(ready=())
manager.running_plugins = {}
migration = MagicMock()
async def migrate() -> None:
order.append("migrate")
migration.migrate = migrate
identity = replace(
_legacy(),
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=OFFICIAL_SOURCE,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="9.9.10",
package_generation="v3",
payload_receipt="sha256:" + "5" * 64,
bound_at=NOW,
payload_applied_at=NOW,
)
persistence = MagicMock()
async def get_identity(_plugin_id: str) -> PluginIdentity:
order.append("identity")
return identity
persistence.get_identity = get_identity
config = MagicMock()
config.get.return_value = ["DemoPlugin"]
async def execute(_loop, task, _name):
return task()
monkeypatch.setattr(
plugins_initializer.global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
monkeypatch.setattr(
plugins_initializer,
"configure_plugin_services",
lambda: order.append("configure"),
)
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
monkeypatch.setattr(
plugins_initializer,
"get_plugin_identity_migration",
lambda: migration,
)
monkeypatch.setattr(
plugins_initializer,
"get_plugin_persistence",
lambda: persistence,
)
monkeypatch.setattr(
plugins_initializer,
"get_configured_system_config",
lambda: config,
)
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
assert await plugins_initializer.sync_plugins() is False
assert order == ["configure", "migrate", "identity", "sync"]
@pytest.mark.asyncio
async def test_sync_stops_before_automatic_install_when_identity_migration_fails(
monkeypatch,
) -> None:
"""存量身份无法持久化时,启动同步不得继续读取或替换插件载荷。"""
manager = MagicMock()
manager.mutation.return_value = nullcontext()
migration = MagicMock()
migration.migrate = AsyncMock(side_effect=RuntimeError("database unavailable"))
monkeypatch.setattr(
plugins_initializer,
"configure_plugin_services",
lambda: None,
)
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
monkeypatch.setattr(
plugins_initializer,
"get_plugin_identity_migration",
lambda: migration,
)
assert await plugins_initializer.sync_plugins() is False
manager.sync.assert_not_called()
+388
View File
@@ -0,0 +1,388 @@
"""插件来源身份专用转换命令的 CAS 合同测试。"""
from dataclasses import replace
from datetime import datetime, timedelta, timezone
import pytest
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginIdentityConflictError,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore
from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel
from app.db.uow import SqlAlchemyUnitOfWork
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
def _identity(
plugin_id: str = "DemoPlugin",
*,
trusted_source_type: TrustedPluginSourceType = TrustedPluginSourceType.OFFICIAL,
trusted_source_key: str | None = OFFICIAL_SOURCE,
binding_basis: PluginBindingBasis = PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type: PluginPayloadSourceType = PluginPayloadSourceType.OFFICIAL,
payload_source_key: str | None = OFFICIAL_SOURCE,
) -> PluginIdentity:
"""构造一份带完整在线载荷审计事实的插件身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=trusted_source_type,
trusted_source_key=trusted_source_key,
binding_basis=binding_basis,
payload_source_type=payload_source_type,
payload_source_key=payload_source_key,
declared_version="1.0.0",
package_generation="v3",
system_version=None,
supports_v3=None,
supports_v3t=None,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=NOW,
updated_at=NOW,
bound_at=NOW if trusted_source_type is not TrustedPluginSourceType.UNKNOWN else None,
payload_applied_at=NOW,
)
@pytest.fixture
def identity_store(tmp_path):
"""创建可验证事务回滚和 revision CAS 的独立 SQLite 身份表。"""
engine = sa.create_engine(f"sqlite:///{tmp_path / 'plugin-identity.db'}")
PluginIdentityModel.__table__.create(engine)
factory = sessionmaker(bind=engine)
try:
yield TransactionalPluginIdentityStore(factory)
finally:
engine.dispose()
def _third_party_target(identity: PluginIdentity) -> PluginIdentity:
"""构造一次明确指向第三方在线仓库的换源目标。"""
return replace(
identity,
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
trusted_source_key=THIRD_PARTY_SOURCE,
binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE,
payload_source_type=PluginPayloadSourceType.THIRD_PARTY,
payload_source_key=THIRD_PARTY_SOURCE,
declared_version="2.0.0",
updated_at=NOW + timedelta(seconds=1),
bound_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
def _legacy_identity(plugin_id: str = "DemoPlugin") -> PluginIdentity:
"""构造尚未建立可信来源且没有已知载荷的存量身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
declared_version=None,
package_generation=None,
system_version=None,
supports_v3=None,
supports_v3t=None,
payload_receipt=None,
revision=1,
created_at=NOW,
updated_at=NOW,
bound_at=None,
payload_applied_at=None,
)
def _online_binding_target(
identity: PluginIdentity,
*,
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
source_key: str = THIRD_PARTY_SOURCE,
updated_at: datetime = NOW + timedelta(seconds=1),
) -> PluginIdentity:
"""构造用户明确选定在线仓库后的首次绑定目标。"""
return replace(
identity,
trusted_source_type=source_type,
trusted_source_key=source_key,
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
payload_source_type=PluginPayloadSourceType(source_type.value),
payload_source_key=source_key,
declared_version="2.0.0",
package_generation="v3",
payload_receipt="sha256:" + "2" * 64,
updated_at=updated_at,
bound_at=updated_at,
payload_applied_at=updated_at,
)
def test_change_source_commits_explicit_online_transition(identity_store) -> None:
"""显式换源必须保留创建时间并只推进一个 revision。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
changed = identity_store.change_source(
_third_party_target(original),
expected_revision=original.revision,
)
assert changed.trusted_source_type is TrustedPluginSourceType.THIRD_PARTY
assert changed.trusted_source_key == THIRD_PARTY_SOURCE
assert changed.payload_source_type is PluginPayloadSourceType.THIRD_PARTY
assert changed.payload_source_key == THIRD_PARTY_SOURCE
assert changed.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
assert changed.created_at == original.created_at
assert changed.revision == original.revision + 1
assert identity_store.get(original.plugin_id) == changed
def test_change_source_rejects_revision_competition(identity_store) -> None:
"""换源目标使用旧 revision 时不能覆盖已经提交的身份。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
changed = identity_store.change_source(
_third_party_target(original),
expected_revision=original.revision,
)
stale_target = replace(
_third_party_target(original),
trusted_source_key=OFFICIAL_SOURCE,
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
payload_source_key=OFFICIAL_SOURCE,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
updated_at=NOW + timedelta(seconds=2),
bound_at=NOW + timedelta(seconds=2),
payload_applied_at=NOW + timedelta(seconds=2),
)
with pytest.raises(PluginIdentityConflictError, match="revision"):
identity_store.change_source(stale_target, expected_revision=original.revision)
assert identity_store.get(original.plugin_id) == changed
def test_change_source_rejects_same_source_and_local_payload(identity_store) -> None:
"""换源必须改变实际在线来源,且不能以本地载荷冒充在线换源。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
same_source = replace(
original,
binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE,
updated_at=NOW + timedelta(seconds=1),
bound_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
with pytest.raises(PluginIdentityConflictError, match="来源必须变化"):
identity_store.change_source(
same_source,
expected_revision=original.revision,
)
local_payload = replace(
_third_party_target(original),
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
)
with pytest.raises(PluginIdentityConflictError, match="在线载荷"):
identity_store.change_source(
local_payload,
expected_revision=original.revision,
)
assert identity_store.get(original.plugin_id) == original
def test_bind_local_commits_only_legacy_unbound_transition(identity_store) -> None:
"""本地绑定只能把存量未绑定行转换为本地专属身份。"""
legacy = _legacy_identity()
original = identity_store.compare_and_set(legacy, expected_revision=None)
local = replace(
original,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
changed = identity_store.bind_local(
local,
expected_revision=original.revision,
)
assert changed.trusted_source_type is TrustedPluginSourceType.UNKNOWN
assert changed.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert changed.payload_source_type is PluginPayloadSourceType.LOCAL
assert changed.created_at == original.created_at
assert changed.revision == 2
assert identity_store.get(original.plugin_id) == changed
def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) -> None:
"""显式在线安装可绑定存量未知来源,也可承接先本地开发的插件。"""
legacy = identity_store.compare_and_set(
_legacy_identity("LegacyPlugin"),
expected_revision=None,
)
legacy_bound = identity_store.bind_online(
_online_binding_target(legacy),
expected_revision=legacy.revision,
)
local = replace(
_legacy_identity("LocalPlugin"),
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
local = identity_store.compare_and_set(local, expected_revision=None)
local_bound = identity_store.bind_online(
_online_binding_target(
local,
source_type=TrustedPluginSourceType.OFFICIAL,
source_key=OFFICIAL_SOURCE,
updated_at=NOW + timedelta(seconds=2),
),
expected_revision=local.revision,
)
assert legacy_bound.trusted_source_key == THIRD_PARTY_SOURCE
assert legacy_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
assert legacy_bound.revision == 2
assert local_bound.trusted_source_key == OFFICIAL_SOURCE
assert local_bound.payload_source_type is PluginPayloadSourceType.OFFICIAL
assert local_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
assert local_bound.revision == 2
def test_bind_online_rejects_bound_identity_and_stale_revision(identity_store) -> None:
"""首次在线绑定不能覆盖已有可信来源,也不能使用失效 revision。"""
bound = identity_store.compare_and_set(_identity(), expected_revision=None)
with pytest.raises(PluginIdentityConflictError, match="未绑定"):
identity_store.bind_online(
_third_party_target(bound),
expected_revision=bound.revision,
)
legacy = identity_store.compare_and_set(
_legacy_identity("StalePlugin"),
expected_revision=None,
)
target = _online_binding_target(legacy)
identity_store.bind_online(target, expected_revision=legacy.revision)
with pytest.raises(PluginIdentityConflictError, match="revision"):
identity_store.bind_online(target, expected_revision=legacy.revision)
def test_first_local_install_still_uses_ordinary_create(identity_store) -> None:
"""未安装插件的首次本地载荷仍可由普通 create 建立身份。"""
local = replace(
_identity(),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="2.0.0-dev",
payload_receipt="sha256:" + "1" * 64,
bound_at=None,
)
created = identity_store.compare_and_set(local, expected_revision=None)
assert created.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert created.payload_source_type is PluginPayloadSourceType.LOCAL
assert created.revision == 1
def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -> None:
"""本地绑定不能绕过已绑定身份或 revision 条件。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
local = replace(
original,
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
bound_at=None,
declared_version="2.0.0-dev",
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
with pytest.raises(PluginIdentityConflictError, match="legacy_unbound"):
identity_store.bind_local(local, expected_revision=original.revision)
legacy = replace(
_identity("LegacyPlugin"),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
declared_version=None,
package_generation=None,
payload_receipt=None,
bound_at=None,
payload_applied_at=None,
)
identity_store.compare_and_set(legacy, expected_revision=None)
changed = replace(
legacy,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
payload_receipt="sha256:" + "1" * 64,
updated_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
identity_store.bind_local(changed, expected_revision=1)
with pytest.raises(PluginIdentityConflictError, match="revision"):
identity_store.bind_local(changed, expected_revision=1)
def test_ordinary_writer_still_rejects_binding_change(identity_store) -> None:
"""普通 writer 不能借 CAS 参数伪装成来源绑定转换。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
with pytest.raises(PluginIdentityConflictError, match="不能改变"):
identity_store.compare_and_set(
_third_party_target(original),
expected_revision=original.revision,
)
assert identity_store.get(original.plugin_id) == original
def test_transition_rolls_back_when_commit_fails(identity_store, monkeypatch) -> None:
"""转换提交失败时必须回滚暂存的身份变化。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
target = _third_party_target(original)
def fail_commit(_unit_of_work: SqlAlchemyUnitOfWork) -> None:
"""模拟数据库提交失败。"""
raise RuntimeError("commit failed")
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
identity_store.change_source(target, expected_revision=original.revision)
assert identity_store.get(original.plugin_id) == original
+375
View File
@@ -0,0 +1,375 @@
"""插件来源准入与目标身份规划测试。"""
from dataclasses import replace
from datetime import datetime, timedelta, timezone
import pytest
from app.application.plugin.admission import (
PluginInstallAdmissionRequest,
PluginSourceAdmissionError,
admit_plugin_install,
)
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.source import (
CandidateInventory,
MarketRead,
PluginLocalCandidate,
PluginMarketCandidate,
)
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
OFFICIAL = "github:jxxghp/moviepilot-plugins"
THIRD_PARTY = "github:example/moviepilot-plugins"
def _online_candidate(
*,
source_key: str = OFFICIAL,
source_type: TrustedPluginSourceType = TrustedPluginSourceType.OFFICIAL,
) -> PluginMarketCandidate:
"""构造一个可安装在线候选。"""
owner_repo = source_key.removeprefix("github:")
return PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key=source_key,
source_type=source_type,
repo_url=f"https://github.com/{owner_repo}",
package_generation="v3",
plugin_version="2.0.0",
dto={"system_version": ">=3.0.0", "v3": True, "v3t": False},
)
def _identity() -> PluginIdentity:
"""构造已经绑定官方来源的旧载荷身份。"""
return PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=OFFICIAL,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=OFFICIAL,
declared_version="1.0.0",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
payload_receipt="sha256:" + "0" * 64,
revision=3,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
def _inventory(*candidates, local_candidates=()) -> CandidateInventory:
"""构造完整市场库存。"""
return CandidateInventory(
(MarketRead.present("https://github.com/example/plugins", candidates),),
tuple(local_candidates),
)
def test_same_source_update_preserves_binding_and_advances_payload() -> None:
"""同源更新只推进载荷事实,不改变既有可信绑定依据。"""
current = _identity()
admission = admit_plugin_install(
_inventory(_online_candidate()),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
explicit_source=False,
),
identity=current,
now=NOW,
)
target = admission.build_identity(
payload_receipt="sha256:" + "1" * 64,
applied_at=NOW,
)
assert target.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
assert target.trusted_source_key == OFFICIAL
assert target.revision == 4
assert target.declared_version == "2.0.0"
def test_first_online_binding_uses_payload_commit_time() -> None:
"""首次在线绑定在载荷提交时生效,不能早于身份创建时间。"""
applied_at = NOW + timedelta(seconds=1)
admission = admit_plugin_install(
_inventory(_online_candidate()),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
explicit_source=True,
),
identity=None,
now=NOW,
)
target = admission.build_identity(
payload_receipt="sha256:" + "6" * 64,
applied_at=applied_at,
)
assert target.created_at == applied_at
assert target.updated_at == applied_at
assert target.bound_at == applied_at
assert target.payload_applied_at == applied_at
def test_force_semantics_cannot_authorize_source_change() -> None:
"""普通安装即使替换载荷,也不能选择不同于已绑定来源的仓库。"""
with pytest.raises(PluginSourceAdmissionError, match="普通安装不能改变"):
admit_plugin_install(
_inventory(
_online_candidate(),
_online_candidate(
source_key=THIRD_PARTY,
source_type=TrustedPluginSourceType.THIRD_PARTY,
),
),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="https://github.com/example/moviepilot-plugins",
explicit_source=True,
),
identity=_identity(),
now=NOW,
)
@pytest.mark.parametrize("revision", [None, 2, 4])
def test_source_change_requires_exact_identity_revision(revision: int | None) -> None:
"""显式换源必须携带当前身份的精确 revision。"""
with pytest.raises(PluginSourceAdmissionError, match="revision"):
admit_plugin_install(
_inventory(
_online_candidate(
source_key=THIRD_PARTY,
source_type=TrustedPluginSourceType.THIRD_PARTY,
)
),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="https://github.com/example/moviepilot-plugins",
explicit_source=True,
source_change=True,
expected_revision=revision,
),
identity=_identity(),
now=NOW,
)
def test_source_change_builds_explicit_transition() -> None:
"""合法换源把 trusted 与 payload 一起指向明确选择的新仓库。"""
candidate = _online_candidate(
source_key=THIRD_PARTY,
source_type=TrustedPluginSourceType.THIRD_PARTY,
)
admission = admit_plugin_install(
_inventory(candidate),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url=candidate.repo_url,
explicit_source=True,
source_change=True,
expected_revision=3,
),
identity=_identity(),
now=NOW,
)
applied_at = NOW + timedelta(seconds=1)
target = admission.build_identity(
payload_receipt="sha256:" + "2" * 64,
applied_at=applied_at,
)
assert target.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
assert target.trusted_source_key == THIRD_PARTY
assert target.payload_source_key == THIRD_PARTY
assert target.bound_at == applied_at
def test_local_payload_preserves_existing_online_trust() -> None:
"""本地开发载荷覆盖时保留此前可信在线来源,便于之后同源恢复。"""
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?package_version=v3",
package_generation="v3",
plugin_version="2.0.0-dev",
dto={"v3": True},
)
admission = admit_plugin_install(
_inventory(local_candidates=(local,)),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url=local.repo_url,
explicit_source=True,
),
identity=_identity(),
now=NOW,
)
target = admission.build_identity(
payload_receipt="sha256:" + "3" * 64,
applied_at=NOW,
)
assert target.trusted_source_key == OFFICIAL
assert target.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
assert target.payload_source_type is PluginPayloadSourceType.LOCAL
assert target.payload_source_key is None
def test_first_local_payload_creates_local_only_identity() -> None:
"""首次本地安装不会伪造在线可信来源。"""
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?package_version=v3",
package_generation="v3",
plugin_version="2.0.0-dev",
dto={"v3": True},
)
admission = admit_plugin_install(
_inventory(local_candidates=(local,)),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url=local.repo_url,
explicit_source=True,
),
identity=None,
now=NOW,
)
target = admission.build_identity(
payload_receipt="sha256:" + "4" * 64,
applied_at=NOW,
)
assert target.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert target.trusted_source_type is TrustedPluginSourceType.UNKNOWN
def test_sanitized_local_reference_selects_configured_candidate_without_path() -> None:
"""脱敏本地来源标识仍能选择配置内候选,但公共投影不暴露路径。"""
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url=(
"local://DemoPlugin?path=/private/secret/plugins&version=v3"
),
package_generation="v3",
plugin_version="2.0.0-dev",
dto={"v3": True, "path": "/private/secret/plugins"},
)
admission = admit_plugin_install(
_inventory(local_candidates=(local,)),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="local://DemoPlugin?version=v3",
),
identity=None,
now=NOW,
)
assert admission.candidate is local
public = admission.candidate.public_dict()
assert public == {
"plugin_id": "DemoPlugin",
"source_type": "local",
"package_generation": "v3",
"plugin_version": "2.0.0-dev",
}
assert "/private/secret/plugins" not in str(public)
def test_legacy_identity_can_bind_explicit_online_source() -> None:
"""存量未绑定身份可在管理员明确选源后建立在线可信来源。"""
legacy = replace(
_identity(),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
declared_version=None,
package_generation=None,
supports_v3=None,
payload_receipt=None,
bound_at=None,
payload_applied_at=None,
)
candidate = _online_candidate(
source_key=THIRD_PARTY,
source_type=TrustedPluginSourceType.THIRD_PARTY,
)
admission = admit_plugin_install(
_inventory(candidate),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url=candidate.repo_url,
explicit_source=True,
),
identity=legacy,
now=NOW,
)
target = admission.build_identity(
payload_receipt="sha256:" + "5" * 64,
applied_at=NOW,
)
assert target.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
assert target.trusted_source_key == THIRD_PARTY
assert target.revision == 4
def test_source_change_rejects_local_payload_reference() -> None:
"""带 revision 的显式换源只能切换在线可信来源。"""
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
package_generation="v3",
plugin_version="2.0.0-dev",
)
current = _identity()
with pytest.raises(
PluginSourceAdmissionError,
match="显式换源只接受在线插件仓库",
):
admit_plugin_install(
_inventory(local_candidates=(local,)),
request=PluginInstallAdmissionRequest(
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_repo_url="local://DemoPlugin?version=v3",
explicit_source=True,
source_change=True,
expected_revision=current.revision,
),
identity=current,
now=NOW,
)
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
"""统一插件安装 Gateway 测试。"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.plugin.gateway import PluginInstallGateway
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.source import (
CandidateInventory,
LocalCandidateRead,
MarketRead,
PluginLocalCandidate,
PluginMarketCandidate,
)
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
def _inventory() -> CandidateInventory:
"""构造仅含官方候选的完整库存。"""
return CandidateInventory((
MarketRead.present(
REPO_URL,
(
PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.0.0",
dto={"v3": True},
),
),
package_generation="v3",
),
))
@pytest.mark.asyncio
async def test_gateway_freezes_admission_before_executing_transaction() -> None:
"""Gateway 只把已选中的候选交给事务执行器。"""
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=_inventory()),
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
explicit_source=True,
)
assert result.success is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate.repo_url == REPO_URL
assert admission.expected_revision is None
@pytest.mark.asyncio
async def test_gateway_rejects_source_conflict_before_package_execution() -> None:
"""来源准入失败时不进入文件和数据库事务。"""
other = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
executor = AsyncMock()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory((
MarketRead.present(REPO_URL, (_inventory().online_candidates[0], other)),
))
),
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=None,
)
assert result.success is False
assert result.failure_stage == "source_admission"
executor.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None:
"""跨仓聚合不能替代最终可信候选的系统版本兼容门禁。"""
official = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.2.0",
dto={"v3": True, "system_version": ">=99"},
)
competing = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="9.9.10",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="9.9.9",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
payload_receipt="sha256:" + "1" * 64,
revision=3,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
compatibility = Mock(return_value=(False, "当前版本不满足插件要求"))
executor = AsyncMock()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory((
MarketRead.present(REPO_URL, (official,)),
MarketRead.present(competing.repo_url, (competing,)),
))
),
identity=AsyncMock(return_value=identity),
candidate_compatibility=compatibility,
executor=executor,
clock=lambda: NOW,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=None,
package_version="v3",
)
assert result.success is False
assert result.failure_stage == "source_admission"
assert result.message == "当前版本不满足插件要求"
compatibility.assert_called_once_with(official)
executor.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_gateway_source_inspection_preserves_sources_and_hides_local_path() -> None:
"""来源查询按在线仓归并版本,本地候选只保留类型与版本。"""
official_v3 = _inventory().online_candidates[0]
official_v2 = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v2",
plugin_version="9.0.0",
dto={"v2": True},
)
third_party = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
package_generation="v3",
plugin_version="3.0.0-dev",
dto={"path": "/private/plugins", "v3": True},
)
inventory = CandidateInventory(
(
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
MarketRead.present(
third_party.repo_url,
(third_party,),
package_generation="v3",
),
),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=AsyncMock(),
clock=lambda: NOW,
)
inspection = await gateway.inspect_source(plugin_id="DemoPlugin")
assert [candidate.source_key for candidate in inspection.online_candidates] == [
"github:jxxghp/moviepilot-plugins",
"github:example/moviepilot-plugins",
]
assert inspection.online_candidates[0].package_generation == "v3"
assert inspection.local_candidate is local
assert "/private/plugins" not in str(inspection.local_candidate.public_dict())
@pytest.mark.asyncio
async def test_gateway_forwards_explicit_source_change_revision() -> None:
"""显式换源的目标来源和 revision 必须冻结到事务准入结果。"""
current = PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="1.0.0",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
payload_receipt="sha256:" + "0" * 64,
revision=4,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
candidate = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory((MarketRead.present(REPO_URL, (candidate,)),))
),
identity=AsyncMock(return_value=current),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=candidate.repo_url,
explicit_source=True,
source_change=True,
expected_revision=4,
)
assert result.success is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.identity_before == current
assert admission.expected_revision == 4
assert admission.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
assert admission.trusted_source_key == candidate.source_key
+728
View File
@@ -0,0 +1,728 @@
"""插件安装事务记录、SQLite CAS 和 membership 测试。"""
import copy
import importlib
import os
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy.orm import sessionmaker
try:
import psycopg2 as postgres_driver
from psycopg2 import sql
POSTGRESQL_DIALECT = "postgresql+psycopg2"
except ModuleNotFoundError:
import psycopg as postgres_driver
from psycopg import sql
POSTGRESQL_DIALECT = "postgresql+psycopg"
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.transaction import (
PluginInstallationConflictError,
PluginInstallationPhase,
PluginInstallationRecord,
PluginInstallationRecordError,
)
from app.db.adapters.plugininstallation import TransactionalPluginInstallationStore
from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel
from app.db.models.plugininstallation import PluginInstallation
from app.db.models.systemconfig import SystemConfig
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
def _identity(
*,
plugin_id: str = "DemoPlugin",
revision: int = 1,
version: str = "1.0.0",
) -> PluginIdentity:
"""构造一份满足来源身份合同的测试身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version=version,
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=False,
payload_receipt="sha256:" + "0" * 64,
revision=revision,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
def _record(**overrides) -> PluginInstallationRecord:
"""构造可跨进程恢复的安装事务记录。"""
values = {
"transaction_id": "txn-demo-1",
"plugin_id": "DemoPlugin",
"phase": PluginInstallationPhase.PREPARED,
"membership_before": True,
"membership_target": None,
"identity_before_revision": 1,
"identity_target_revision": None,
"package_existed": True,
"persistent_backup_existed": True,
"created_at": NOW,
"updated_at": NOW,
}
values.update(overrides)
return PluginInstallationRecord(**values)
def test_record_keeps_plugin_level_recovery_contract() -> None:
"""事务只记录目标插件 membership、CAS revision 和备份存在性。"""
record = _record(
phase="committed",
membership_target=True,
identity_target_revision=2,
)
assert record.phase is PluginInstallationPhase.COMMITTED
assert record.membership_before is True
assert record.membership_target is True
assert record.identity_before_revision == 1
assert record.identity_target_revision == 2
assert record.package_existed is True
assert record.persistent_backup_existed is True
@pytest.mark.parametrize(
"overrides",
[
{"transaction_id": "bad id"},
{"plugin_id": " DemoPlugin"},
{"membership_before": 1},
{"membership_target": 1},
{"identity_before_revision": 0},
{"identity_target_revision": True},
{"package_existed": 1},
{"created_at": NOW.replace(tzinfo=None)},
{"updated_at": NOW.replace(year=2025)},
{"phase": "committed"},
],
)
def test_record_rejects_invalid_recovery_invariants(overrides: dict) -> None:
"""事务记录必须拒绝不能用于 CAS 或补偿恢复的状态。"""
with pytest.raises(PluginInstallationRecordError):
_record(**overrides)
def test_committed_record_requires_target_membership() -> None:
"""COMMITTED 不能指向尚未登记的业务目标。"""
with pytest.raises(PluginInstallationRecordError):
_record(phase=PluginInstallationPhase.COMMITTED)
def test_record_schema_version_is_explicit() -> None:
"""恢复读取必须拒绝未知 schema version。"""
with pytest.raises(PluginInstallationRecordError):
_record(schema_version=2)
def test_record_is_immutable() -> None:
"""事务记录提交后不能被调用方原地修改。"""
record = _record()
with pytest.raises(AttributeError):
record.membership_before = False # type: ignore[misc]
assert replace(record, membership_before=False).membership_before is False
class _AtomicSystemConfig:
"""用测试 Session 模拟 SystemConfigOper 的配置锁和原子提交。"""
def __init__(self, factory) -> None:
self._factory = factory
self._lock = threading.RLock()
def update_atomically(self, key, mutation):
"""在测试数据库事务中锁定配置并执行关联写入。"""
with self._lock:
session = self._factory()
try:
with session.begin():
config = session.execute(
sa.select(SystemConfig)
.where(SystemConfig.key == key)
.with_for_update()
).scalar_one_or_none()
current = copy.deepcopy(config.value if config else None)
result, value = mutation(session, current)
if config is None:
session.add(SystemConfig(key=key, value=copy.deepcopy(value)))
else:
config.value = copy.deepcopy(value)
session.flush()
return result
finally:
session.close()
@pytest.fixture
def installation_store(tmp_path):
"""创建带配置、身份和事务表的隔离 SQLite Store。"""
engine = sa.create_engine(
f"sqlite:///{tmp_path / 'plugin-installation.db'}",
connect_args={"check_same_thread": False, "timeout": 5},
)
for model in (SystemConfig, PluginIdentityModel, PluginInstallation):
model.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
system_config = _AtomicSystemConfig(factory)
try:
yield engine, factory, TransactionalPluginInstallationStore(
factory,
system_config.update_atomically,
)
finally:
engine.dispose()
def _store_record(
*,
transaction_id: str,
plugin_id: str = "DemoPlugin",
membership_before: bool = False,
identity_before_revision: int | None = None,
) -> PluginInstallationRecord:
"""构造 Store 测试用的 PREPARED 记录。"""
return PluginInstallationRecord(
transaction_id=transaction_id,
plugin_id=plugin_id,
phase=PluginInstallationPhase.PREPARED,
membership_before=membership_before,
membership_target=None,
identity_before_revision=identity_before_revision,
identity_target_revision=None,
package_existed=membership_before,
persistent_backup_existed=False,
created_at=NOW,
updated_at=NOW,
)
def _identity_model(identity: PluginIdentity) -> PluginIdentityModel:
"""把应用身份转换为测试数据库模型。"""
return PluginIdentityModel(
plugin_id=identity.plugin_id,
normalized_plugin_id=identity.normalized_plugin_id,
trusted_source_type=identity.trusted_source_type.value,
trusted_source_key=identity.trusted_source_key,
binding_basis=identity.binding_basis.value,
payload_source_type=identity.payload_source_type.value,
payload_source_key=identity.payload_source_key,
declared_version=identity.declared_version,
package_generation=identity.package_generation,
supports_v3=identity.supports_v3,
supports_v3t=identity.supports_v3t,
payload_receipt=identity.payload_receipt,
revision=identity.revision,
created_at=identity.created_at.isoformat(),
updated_at=identity.updated_at.isoformat(),
bound_at=identity.bound_at.isoformat() if identity.bound_at else None,
payload_applied_at=(
identity.payload_applied_at.isoformat()
if identity.payload_applied_at
else None
),
)
def _set_config(factory, value: list[str]) -> None:
"""直接准备测试用的安装清单。"""
with factory() as session:
config = session.execute(
sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins")
).scalar_one_or_none()
if config is None:
session.add(SystemConfig(key="UserInstalledPlugins", value=value))
else:
config.value = value
session.commit()
def _get_config(factory) -> list[str] | None:
"""读取测试用的安装清单。"""
with factory() as session:
config = session.execute(
sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins")
).scalar_one_or_none()
return copy.deepcopy(config.value) if config else None
def _upgrade_migration(connection, module_name: str) -> None:
"""在当前隔离 schema 中按生产 Alembic 路径执行迁移。"""
migration = importlib.import_module(module_name)
original_op = migration.op
try:
migration.op = Operations(MigrationContext.configure(connection))
migration.upgrade()
finally:
migration.op = original_op
def _set_identity_revision(
factory,
revision: int,
plugin_id: str = "DemoPlugin",
) -> None:
"""模拟事务外的身份 revision 更新。"""
with factory() as session:
identity = session.execute(
sa.select(PluginIdentityModel).where(
PluginIdentityModel.normalized_plugin_id == plugin_id.lower()
)
).scalar_one()
identity.revision = revision
session.commit()
def test_store_round_trips_plugin_level_journal(installation_store) -> None:
"""SQLite 往返只保留插件级 membership、revision 和备份标记。"""
_, _, store = installation_store
record = _store_record(transaction_id="install-roundtrip")
store.create(record)
restored = store.get(record.transaction_id)
assert restored == record
@pytest.mark.parametrize(
"phase",
[PluginInstallationPhase.PREPARED, PluginInstallationPhase.COMMITTED],
)
def test_store_blocks_new_journal_until_previous_phase_is_closed(
installation_store,
phase: PluginInstallationPhase,
) -> None:
"""同一物理插件的未收尾 journal 不得被后续事务覆盖。"""
_, _, store = installation_store
existing = _store_record(transaction_id=f"install-{phase.value}")
if phase is PluginInstallationPhase.COMMITTED:
existing = replace(existing, phase=phase, membership_target=True)
store.create(existing)
with pytest.raises(PluginInstallationConflictError, match="未收尾安装事务"):
store.create(
_store_record(
transaction_id="install-follow-up",
plugin_id="demoplugin",
)
)
assert store.get(existing.transaction_id).phase is phase
assert store.delete(
existing.transaction_id,
expected_phase=phase,
) is True
assert store.create(
_store_record(
transaction_id="install-follow-up",
plugin_id="demoplugin",
)
).transaction_id == "install-follow-up"
def test_store_commits_membership_identity_and_phase_atomically(installation_store) -> None:
"""membership、身份和 journal phase 必须在一个配置原子事务中提交。"""
_, factory, store = installation_store
before = _identity()
target = replace(
before,
declared_version="2.0.0",
revision=2,
updated_at=NOW.replace(second=1),
payload_applied_at=NOW.replace(second=1),
)
_set_config(factory, ["OtherPlugin"])
with factory() as session:
session.add(_identity_model(before))
session.commit()
store.create(
_store_record(
transaction_id="install-atomic",
identity_before_revision=before.revision,
)
)
staged = store.set_target(
"install-atomic",
membership_target=True,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert staged.identity_target_revision == target.revision
committed = store.commit_target(
"install-atomic",
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert committed.phase is PluginInstallationPhase.COMMITTED
assert _get_config(factory) == ["OtherPlugin", "DemoPlugin"]
with factory() as session:
identity = session.execute(
sa.select(PluginIdentityModel).where(
PluginIdentityModel.normalized_plugin_id == "demoplugin"
)
).scalar_one()
assert identity.revision == 2
def test_store_preserves_other_plugin_membership(installation_store) -> None:
"""目标插件提交不能用旧完整清单覆盖其他插件。"""
_, factory, store = installation_store
_set_config(factory, ["OtherPlugin"])
store.create(_store_record(transaction_id="install-narrow"))
store.set_target(
"install-narrow",
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
_set_config(factory, ["OtherPlugin", "AnotherPlugin"])
committed = store.commit_target(
"install-narrow",
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert committed.phase is PluginInstallationPhase.COMMITTED
assert _get_config(factory) == ["OtherPlugin", "AnotherPlugin", "DemoPlugin"]
def test_store_rejects_target_identity_revision_jump(installation_store) -> None:
"""最终写者必须拒绝跳号 revision,避免绕过后续来源 CAS。"""
_, factory, store = installation_store
before = _identity()
with factory() as session:
session.add(_identity_model(before))
session.commit()
store.create(
_store_record(
transaction_id="install-revision-jump",
identity_before_revision=before.revision,
)
)
jumped = replace(
before,
revision=before.revision + 2,
updated_at=NOW.replace(second=1),
)
with pytest.raises(PluginInstallationConflictError, match="必须为 2"):
store.set_target(
"install-revision-jump",
membership_target=True,
identity_target=jumped,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert store.get("install-revision-jump").identity_target_revision is None
def test_store_rejects_membership_and_identity_cas_drift(installation_store) -> None:
"""同一插件 membership 或 identity revision 漂移时拒绝覆盖。"""
_, factory, store = installation_store
before = _identity()
with factory() as session:
session.add(_identity_model(before))
session.commit()
store.create(
_store_record(
transaction_id="install-drift",
identity_before_revision=before.revision,
)
)
target = replace(before, revision=2, updated_at=NOW.replace(second=1))
store.set_target(
"install-drift",
membership_target=True,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
_set_config(factory, ["DemoPlugin"])
with pytest.raises(PluginInstallationConflictError, match="membership"):
store.commit_target(
"install-drift",
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert store.get("install-drift").phase is PluginInstallationPhase.PREPARED
_set_config(factory, [])
_set_identity_revision(factory, 3)
with pytest.raises(PluginInstallationConflictError, match="revision"):
store.commit_target(
"install-drift",
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
def _commit_or_conflict(store, transaction_id: str) -> str:
"""把 phase CAS 竞争转换为可断言的测试结果。"""
try:
store.commit_target(
transaction_id,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
except PluginInstallationConflictError:
return "conflict"
return "committed"
def test_store_serializes_membership_commits_and_phase_cas(installation_store) -> None:
"""SQLite 下不同插件并发提交应合并,重复提交同一事务只能失败。"""
_, factory, store = installation_store
first = _store_record(transaction_id="install-first")
second = _store_record(transaction_id="install-second", plugin_id="OtherPlugin")
store.create(first)
store.create(second)
store.set_target(
first.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
store.set_target(
second.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
def commit(record_id: str):
return store.commit_target(
record_id,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(commit, [first.transaction_id, second.transaction_id])
)
assert {result.phase for result in results} == {
PluginInstallationPhase.COMMITTED,
}
assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"}
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(
executor.map(
lambda _: _commit_or_conflict(store, first.transaction_id),
range(2),
)
)
assert outcomes == ["conflict", "conflict"]
def test_store_delete_is_idempotent_after_recovery(installation_store) -> None:
"""恢复处理重复清理同一 journal 时不产生第二次副作用。"""
_, _, store = installation_store
store.create(_store_record(transaction_id="install-delete"))
assert store.delete(
"install-delete",
expected_phase=PluginInstallationPhase.PREPARED,
) is True
assert store.delete(
"install-delete",
expected_phase=PluginInstallationPhase.PREPARED,
) is False
@pytest.fixture
def postgresql_installation_stores():
"""创建两个不共享进程锁的 PostgreSQL Store,验证数据库并发合同。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
host = os.getenv(f"{prefix}HOST")
database = os.getenv(f"{prefix}DATABASE")
username = os.getenv(f"{prefix}USERNAME")
if not host or not database or not username:
pytest.skip("未配置隔离 PostgreSQL transaction 测试库")
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"plugin_transaction_{uuid.uuid4().hex}"
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
)
engine = sa.create_engine(
sa.URL.create(
POSTGRESQL_DIALECT,
username=username,
password=password,
host=host,
port=int(port),
database=database,
),
connect_args={"options": f"-csearch_path={schema}"},
)
SystemConfig.__table__.create(engine)
with engine.begin() as connection:
_upgrade_migration(
connection,
"database.versions.d2e4f6a8b0c1_3_0_9",
)
_upgrade_migration(
connection,
"database.versions.e4f7a1b2c3d5_3_0_10",
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
_set_config(factory, [])
first = TransactionalPluginInstallationStore(
factory,
_AtomicSystemConfig(factory).update_atomically,
)
second = TransactionalPluginInstallationStore(
factory,
_AtomicSystemConfig(factory).update_atomically,
)
try:
yield factory, first, second
finally:
engine.dispose()
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
sql.Identifier(schema)
)
)
def test_postgresql_store_serializes_membership_phase_and_revision_cas(
postgresql_installation_stores,
) -> None:
"""PostgreSQL 行锁必须合并不同插件写入并拒绝 phase/revision 竞争。"""
factory, first_store, second_store = postgresql_installation_stores
first = _store_record(transaction_id="postgres-first")
second = _store_record(
transaction_id="postgres-second",
plugin_id="OtherPlugin",
)
for store, record in ((first_store, first), (second_store, second)):
store.create(record)
store.set_target(
record.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(
lambda item: item[0].commit_target(
item[1].transaction_id,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
),
((first_store, first), (second_store, second)),
)
)
assert {result.phase for result in results} == {
PluginInstallationPhase.COMMITTED,
}
assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"}
race = _store_record(
transaction_id="postgres-phase-race",
plugin_id="RacePlugin",
)
first_store.create(race)
first_store.set_target(
race.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
barrier = threading.Barrier(2)
def commit_race(store) -> str:
barrier.wait()
return _commit_or_conflict(store, race.transaction_id)
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(executor.map(commit_race, (first_store, second_store)))
assert sorted(outcomes) == ["committed", "conflict"]
before = _identity(plugin_id="RevisionPlugin")
with factory() as session:
session.add(_identity_model(before))
session.commit()
revision = _store_record(
transaction_id="postgres-revision",
plugin_id=before.plugin_id,
identity_before_revision=before.revision,
)
first_store.create(revision)
target = replace(
before,
revision=2,
updated_at=NOW.replace(second=1),
)
first_store.set_target(
revision.transaction_id,
membership_target=True,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
_set_identity_revision(factory, 3, plugin_id=before.plugin_id)
with pytest.raises(PluginInstallationConflictError, match="revision"):
second_store.commit_target(
revision.transaction_id,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
+140
View File
@@ -0,0 +1,140 @@
"""插件安装事务表 Alembic 迁移测试。"""
import importlib
import os
import uuid
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
try:
import psycopg2 as postgres_driver
from psycopg2 import sql
POSTGRESQL_DIALECT = "postgresql+psycopg2"
except ModuleNotFoundError:
import psycopg as postgres_driver
from psycopg import sql
POSTGRESQL_DIALECT = "postgresql+psycopg"
from app.db.models.plugininstallation import PluginInstallation
MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10"
def _bind_migration(monkeypatch, connection):
"""把迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def test_plugin_installation_migration_upgrade_downgrade_reupgrade(
monkeypatch,
) -> None:
"""SQLite 应支持重复升级、回滚和再次升级,字段与 ORM 保持一致。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
inspector = sa.inspect(connection)
assert "plugininstallation" in inspector.get_table_names()
assert {
column["name"] for column in inspector.get_columns("plugininstallation")
} == {column.name for column in PluginInstallation.__table__.columns}
assert {
index["name"] for index in inspector.get_indexes("plugininstallation")
} == {
"ix_plugininstallation_plugin_id",
"ix_plugininstallation_phase",
}
migration.downgrade()
assert "plugininstallation" not in sa.inspect(connection).get_table_names()
migration.upgrade()
assert "plugininstallation" in sa.inspect(connection).get_table_names()
def test_plugin_installation_migration_runs_on_postgresql(monkeypatch) -> None:
"""隔离 PostgreSQL 应真实执行安装事务表的升级、约束和回滚。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
host = os.getenv(f"{prefix}HOST")
database = os.getenv(f"{prefix}DATABASE")
username = os.getenv(f"{prefix}USERNAME")
if not host or not database or not username:
pytest.skip("未配置隔离 PostgreSQL migration 测试库")
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"plugin_installation_{uuid.uuid4().hex}"
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
)
engine = None
try:
engine = sa.create_engine(
sa.URL.create(
POSTGRESQL_DIALECT,
username=username,
password=password,
host=host,
port=int(port),
database=database,
),
connect_args={"options": f"-csearch_path={schema}"},
)
with engine.begin() as connection:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
inspector = sa.inspect(connection)
assert "plugininstallation" in inspector.get_table_names()
constraints = {
constraint["name"]
for constraint in inspector.get_unique_constraints(
"plugininstallation"
)
}
assert "uq_plugininstallation_transaction_id" in constraints
migration.downgrade()
assert "plugininstallation" not in sa.inspect(connection).get_table_names()
finally:
if engine is not None:
engine.dispose()
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
sql.Identifier(schema)
)
)
+289
View File
@@ -0,0 +1,289 @@
"""插件安装 journal 启动重放与阻断边界测试。"""
from dataclasses import replace
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.recovery import (
PluginInstallationRecoveryError,
PluginInstallationRecoveryService,
)
from app.application.plugin.transaction import (
PluginInstallationPhase,
PluginInstallationRecord,
)
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
RECEIPT = "sha256:" + "1" * 64
def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity:
"""构造一份已提交载荷身份。"""
return PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="2.0.0",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=True,
payload_receipt=receipt,
revision=revision,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
def _record(
*,
phase: PluginInstallationPhase,
transaction_id: str = "txn-demo",
) -> PluginInstallationRecord:
"""构造 PREPARED 或 COMMITTED 恢复记录。"""
committed = phase is PluginInstallationPhase.COMMITTED
return PluginInstallationRecord(
transaction_id=transaction_id,
plugin_id="DemoPlugin",
phase=phase,
membership_before=True,
membership_target=True if committed else None,
identity_before_revision=1,
identity_target_revision=2 if committed else None,
package_existed=True,
persistent_backup_existed=True,
created_at=NOW,
updated_at=NOW,
)
class _Persistence:
"""保存恢复测试所需 journal、身份和删除故障。"""
def __init__(
self,
records: list[PluginInstallationRecord],
*,
identity: PluginIdentity | None = None,
delete_errors: list[Exception | None] | None = None,
) -> None:
self.records = {record.transaction_id: record for record in records}
self.identity = identity
self.delete_errors = list(delete_errors or [])
self.delete_calls: list[tuple[str, PluginInstallationPhase]] = []
async def list_installations(self) -> list[PluginInstallationRecord]:
"""按创建顺序返回当前 journal。"""
return list(self.records.values())
async def get_identity(self, _plugin_id: str) -> PluginIdentity | None:
"""返回已提交身份。"""
return self.identity
async def delete_installation(
self,
transaction_id: str,
*,
expected_phase: PluginInstallationPhase,
) -> bool:
"""按 phase 删除 journal,并可注入一次性错误。"""
self.delete_calls.append((transaction_id, expected_phase))
if self.delete_errors:
error = self.delete_errors.pop(0)
if error is not None:
raise error
record = self.records.get(transaction_id)
if record is None:
return False
assert record.phase is expected_phase
del self.records[transaction_id]
return True
def _packages(**overrides):
"""构造恢复服务消费的单一包事务端口。"""
checkpoint = SimpleNamespace(
plugin_existed=True,
persistent_backup_existed=True,
)
values = {
"restore_checkpoint": Mock(return_value=checkpoint),
"async_restore": AsyncMock(),
"async_cleanup": AsyncMock(),
"async_committed_payload_receipt": AsyncMock(return_value=RECEIPT),
"async_finalize_persistent_backup": AsyncMock(),
"async_commit": AsyncMock(),
}
values.update(overrides)
return SimpleNamespace(**values)
@pytest.mark.asyncio
async def test_prepared_replay_restores_before_releasing_journal() -> None:
"""PREPARED 必须先恢复旧载荷,再删除 journal 和恢复材料。"""
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
packages = _packages()
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
result = await service.replay()
assert result.restored == 1
assert persistence.records == {}
packages.async_restore.assert_awaited_once()
packages.async_cleanup.assert_awaited_once()
assert persistence.delete_calls == [
("txn-demo", PluginInstallationPhase.PREPARED)
]
@pytest.mark.asyncio
async def test_prepared_delete_failure_keeps_replayable_journal() -> None:
"""恢复完成但 journal 删除失败时,下次启动仍可幂等重放。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.PREPARED)],
delete_errors=[RuntimeError("database unavailable"), None],
)
packages = _packages()
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
with pytest.raises(PluginInstallationRecoveryError, match="未提交安装恢复失败"):
await service.replay()
assert "txn-demo" in persistence.records
packages.async_cleanup.assert_not_awaited()
result = await service.replay()
assert result.restored == 1
assert persistence.records == {}
assert packages.async_restore.await_count == 2
packages.async_cleanup.assert_awaited_once()
@pytest.mark.asyncio
async def test_prepared_restore_failure_blocks_plugin_import() -> None:
"""旧载荷无法恢复时必须保留 journal,并让启动阶段失败。"""
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
packages = _packages(
async_restore=AsyncMock(side_effect=RuntimeError("snapshot missing"))
)
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
with pytest.raises(PluginInstallationRecoveryError, match="snapshot missing"):
await service.replay()
assert "txn-demo" in persistence.records
assert persistence.delete_calls == []
packages.async_cleanup.assert_not_awaited()
@pytest.mark.asyncio
async def test_committed_replay_verifies_identity_and_receipt_before_cleanup() -> None:
"""COMMITTED 只在身份 revision 和载荷收据一致时完成幂等收尾。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.COMMITTED)],
identity=_identity(),
)
packages = _packages()
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
result = await service.replay()
assert result.finalized == 1
assert persistence.records == {}
packages.async_committed_payload_receipt.assert_awaited_once()
packages.async_finalize_persistent_backup.assert_awaited_once()
packages.async_commit.assert_awaited_once()
assert persistence.delete_calls == [
("txn-demo", PluginInstallationPhase.COMMITTED)
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("identity", "receipt", "message"),
[
(replace(_identity(), revision=3), RECEIPT, "身份与安装 journal 不一致"),
(_identity(), "sha256:" + "2" * 64, "载荷收据不一致"),
],
)
async def test_committed_fact_mismatch_blocks_plugin_import(
identity: PluginIdentity,
receipt: str,
message: str,
) -> None:
"""已提交数据库事实与可恢复载荷不一致时不得继续加载插件。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.COMMITTED)],
identity=identity,
)
packages = _packages(
async_committed_payload_receipt=AsyncMock(return_value=receipt)
)
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
with pytest.raises(PluginInstallationRecoveryError, match=message):
await service.replay()
assert "txn-demo" in persistence.records
packages.async_finalize_persistent_backup.assert_not_awaited()
packages.async_commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_committed_cleanup_failure_is_retried_without_rollback() -> None:
"""COMMITTED 收尾失败只保留 journal,下一次启动继续清理。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.COMMITTED)],
identity=_identity(),
)
package_commit = AsyncMock(
side_effect=[RuntimeError("snapshot busy"), None]
)
packages = _packages(async_commit=package_commit)
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
first = await service.replay()
assert first.cleanup_pending == 1
assert "txn-demo" in persistence.records
assert persistence.delete_calls == []
second = await service.replay()
assert second.finalized == 1
assert persistence.records == {}
assert packages.async_finalize_persistent_backup.await_count == 2
assert package_commit.await_count == 2
+168
View File
@@ -0,0 +1,168 @@
"""插件生命周期协调器的启动 owner/token 契约测试。"""
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
import pytest
from app.application.plugin.lifecycle import PluginLifecycleCoordinator
async def _assert_event_waits(event: asyncio.Event) -> None:
"""确认事件在短预算内仍未发生,避免测试依赖固定 sleep 时序。"""
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(event.wait()), timeout=0.03)
async def _cancel_task(task: asyncio.Task) -> None:
"""取消仍在等待生命周期资格的任务并消费其终态。"""
task.cancel()
with suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_startup_scope_yields_opaque_token_for_matching_plugin_hold() -> None:
"""启动 owner 取得的 token 可让内部取得逐插件资格。"""
coordinator = PluginLifecycleCoordinator()
async with coordinator.hold_startup() as startup_token:
assert startup_token is not None
assert not isinstance(startup_token, (str, bytes, int, bool))
entered = asyncio.Event()
release = asyncio.Event()
async def hold_plugin() -> None:
async with coordinator.hold("DemoPlugin", startup_token):
entered.set()
await release.wait()
task = asyncio.create_task(hold_plugin())
await entered.wait()
assert coordinator._active_plugins == {"demoplugin"}
release.set()
await task
assert coordinator._active_plugins == set()
@pytest.mark.asyncio
async def test_external_and_duplicate_plugin_holds_remain_blocked() -> None:
"""启动内部的逐插件资格不向外部调用放行,且同插件仍保持互斥。"""
coordinator = PluginLifecycleCoordinator()
internal_release = asyncio.Event()
duplicate_release = asyncio.Event()
internal_entered = asyncio.Event()
duplicate_entered = asyncio.Event()
external_entered = asyncio.Event()
async with coordinator.hold_startup() as startup_token:
async def internal_hold() -> None:
async with coordinator.hold("DemoPlugin", startup_token):
internal_entered.set()
await internal_release.wait()
async def duplicate_hold() -> None:
async with coordinator.hold("demoplugin", startup_token):
duplicate_entered.set()
await duplicate_release.wait()
async def external_hold() -> None:
async with coordinator.hold("DemoPlugin"):
external_entered.set()
internal_task = asyncio.create_task(internal_hold())
await internal_entered.wait()
duplicate_task = asyncio.create_task(duplicate_hold())
external_task = asyncio.create_task(external_hold())
await _assert_event_waits(duplicate_entered)
await _assert_event_waits(external_entered)
internal_release.set()
await internal_task
await duplicate_entered.wait()
await _assert_event_waits(external_entered)
duplicate_release.set()
await duplicate_task
await external_entered.wait()
await external_task
@pytest.mark.asyncio
async def test_foreign_and_expired_tokens_cannot_bypass_current_startup_lease() -> None:
"""其他 coordinator 或旧 lease 的 token 不得绕过当前启动 owner。"""
first = PluginLifecycleCoordinator()
second = PluginLifecycleCoordinator()
async with first.hold_startup() as foreign_token:
async with second.hold_startup() as current_token:
assert foreign_token is not current_token
foreign_entered = asyncio.Event()
async def foreign_hold() -> None:
async with second.hold("DemoPlugin", foreign_token):
foreign_entered.set()
foreign_task = asyncio.create_task(foreign_hold())
await _assert_event_waits(foreign_entered)
await _cancel_task(foreign_task)
async with second.hold_startup() as expired_token:
pass
async with second.hold_startup() as current_token:
assert expired_token is not current_token
expired_entered = asyncio.Event()
async def expired_hold() -> None:
async with second.hold("DemoPlugin", expired_token):
expired_entered.set()
expired_task = asyncio.create_task(expired_hold())
await _assert_event_waits(expired_entered)
await _cancel_task(expired_task)
@pytest.mark.asyncio
async def test_startup_token_can_cross_threads_without_contextvar() -> None:
"""显式 token 可跨线程传递,资格判断不依赖隐式 ContextVar。"""
coordinator = PluginLifecycleCoordinator()
main_thread = threading.current_thread().name
def run_in_thread(startup_token: object) -> tuple[str, set[str]]:
async def hold_plugin() -> tuple[str, set[str]]:
async with coordinator.hold("DemoPlugin", startup_token):
return threading.current_thread().name, set(coordinator._active_plugins)
return asyncio.run(hold_plugin())
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-startup") as executor:
async with coordinator.hold_startup() as startup_token:
result = await asyncio.wrap_future(
executor.submit(run_in_thread, startup_token)
)
assert result[0] != main_thread
assert result[1] == {"demoplugin"}
@pytest.mark.asyncio
async def test_hold_without_token_retains_startup_waiting_compatibility() -> None:
"""无参数调用继续遵守启动全局资格的等待语义。"""
coordinator = PluginLifecycleCoordinator()
async with coordinator.hold_startup():
entered = asyncio.Event()
async def external_hold() -> None:
async with coordinator.hold("DemoPlugin"):
entered.set()
task = asyncio.create_task(external_hold())
await _assert_event_waits(entered)
await _cancel_task(task)
+28 -3
View File
@@ -8,17 +8,40 @@ import pytest
from packaging.version import Version
from watchfiles import Change
from app.adapters.external.market import PluginHelper
from app.foundation.singleton import Singleton
from app.runtime.events import Event, eventmanager
from app.runtime.extensions.plugin_manager import PluginManager
from app.adapters.external.market import PluginHelper
from app.runtime.extensions.plugin.system import get_plugin_system
from app.scheduler import Scheduler
from app.schemas.types import EventType, SystemConfigKey
from app.foundation.singleton import Singleton
@pytest.fixture
def plugin_manager() -> Iterator[PluginManager]:
def plugin_manager(monkeypatch) -> Iterator[PluginManager]:
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
system = get_plugin_system()
def install_local(**kwargs) -> tuple[bool, str]:
"""用测试包适配器模拟已通过来源准入的本地 Gateway。"""
repo_url = kwargs["repo_url"]
candidate = system.local_candidate(
kwargs["plugin_id"],
package_version=kwargs.get("package_version"),
repo_path=PluginHelper.parse_local_repo_path(repo_url),
strict_system_version=False,
)
if not candidate:
return False, "本地候选不存在"
return (
system.package.sync_local(
kwargs["plugin_id"],
Path(candidate["path"]),
),
"",
)
monkeypatch.setattr(system, "install", install_local)
Singleton._instances.pop((PluginManager, (), frozenset()), None)
manager = PluginManager()
yield manager
@@ -65,6 +88,7 @@ def _configure_local_watcher(
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
ROOT_PATH=tmp_path,
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
VERSION_FLAG="v2",
)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
@@ -143,6 +167,7 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l
DEV=True,
ROOT_PATH=tmp_path,
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub)
+111
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
import pytest
from app.adapters.external.market import PluginHelper
from app.adapters.external.plugin.client import PluginMarketClient
@pytest.mark.asyncio
@@ -78,3 +79,113 @@ def test_plugin_index_response_preserves_status_contract(
result = PluginHelper._resolve_plugin_index_response(status_code, content)
assert result == expected
@pytest.mark.parametrize(
("status_code", "content", "expected"),
[
(200, '{"DemoPlugin": {"version": "1.2.3"}}', {"DemoPlugin": {"version": "1.2.3"}}),
(404, "404: Not Found", None),
],
)
def test_plugin_index_result_preserves_read_state(
monkeypatch,
status_code: int,
content: str,
expected: dict | None,
) -> None:
"""只读入口以值和 None 区分真实索引与确定不存在。"""
helper = PluginHelper()
repo_url = f"https://github.com/policy-owner/policy-repository-{status_code}"
def request(_url: str, *, headers: dict):
return SimpleNamespace(status_code=status_code, text=content)
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request)
helper.get_plugin_index_result.cache_clear()
result = helper.get_plugin_index_result(repo_url, "v3")
assert result == expected
@pytest.mark.parametrize(
("status_code", "content", "message"),
[
(500, "upstream failed", "插件索引请求失败:HTTP 500"),
(200, "not-json", "插件索引响应格式无效"),
],
)
def test_plugin_index_result_raises_for_unusable_reads(
monkeypatch,
status_code: int,
content: str,
message: str,
) -> None:
"""不可判定读取必须抛错,由应用库存统一记录失败事实。"""
helper = PluginHelper()
def request(_url: str, *, headers: dict):
return SimpleNamespace(status_code=status_code, text=content)
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request)
helper.get_plugin_index_result.cache_clear()
with pytest.raises(RuntimeError, match=message):
helper.get_plugin_index_result(
f"https://github.com/policy-owner/policy-failed-{status_code}",
"v3",
)
@pytest.mark.asyncio
async def test_async_plugin_index_result_preserves_absent_state(monkeypatch) -> None:
"""异步只读入口也必须保留 404 不存在事实。"""
helper = PluginHelper()
async def request(_url: str, *, headers: dict):
return SimpleNamespace(status_code=404, text="404: Not Found")
monkeypatch.setattr(
helper,
"_PluginHelper__async_request_with_fallback",
request,
)
await helper.async_get_plugin_index_result.cache_clear()
result = await helper.async_get_plugin_index_result(
"https://github.com/policy-owner/policy-repository-async",
"v3",
)
assert result is None
def test_plugin_index_result_propagates_adapter_exception(monkeypatch) -> None:
"""请求异常必须传播给应用库存统一转换为失败事实。"""
helper = PluginHelper()
def request(_url: str, *, headers: dict):
raise OSError("socket closed")
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request)
helper.get_plugin_index_result.cache_clear()
with pytest.raises(OSError, match="socket closed"):
helper.get_plugin_index_result(
"https://github.com/policy-owner/policy-repository-exception",
"v3",
)
def test_plugin_market_client_exposes_index_result_port() -> None:
"""市场客户端应原样转发索引读取结果并保留只读边界。"""
expected = {"DemoPlugin": {"version": "1.2.3"}}
class FakeHelper:
def get_plugin_index_result(self, repo_url: str, package_version: str | None):
return expected
client = PluginMarketClient(FakeHelper())
assert client.get_plugin_index_result("https://github.com/example/repo", "v3") is expected
+48
View File
@@ -154,6 +154,30 @@ def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock:
asyncio.get_running_loop(),
)
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
migration = MagicMock()
migration.migrate = AsyncMock()
monkeypatch.setattr(
plugins_initializer,
"get_plugin_identity_migration",
lambda: migration,
)
config = MagicMock()
config.get.return_value = []
monkeypatch.setattr(
plugins_initializer,
"get_configured_system_config",
lambda: config,
)
monkeypatch.setattr(
plugins_initializer,
"get_plugin_persistence",
MagicMock,
)
monkeypatch.setattr(
plugins_initializer,
"_collect_online_restore_plugins",
AsyncMock(return_value=set()),
)
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
monkeypatch.setattr(plugins_initializer, "register_plugin_api", register)
@@ -347,6 +371,30 @@ async def test_sync_plugins_keeps_event_loop_responsive_during_activation(
return_value=PluginDependencyInstallResult(missing=[], success=True),
)
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
migration = MagicMock()
migration.migrate = AsyncMock()
monkeypatch.setattr(
plugins_initializer,
"get_plugin_identity_migration",
lambda: migration,
)
config = MagicMock()
config.get.return_value = []
monkeypatch.setattr(
plugins_initializer,
"get_configured_system_config",
lambda: config,
)
monkeypatch.setattr(
plugins_initializer,
"get_plugin_persistence",
MagicMock,
)
monkeypatch.setattr(
plugins_initializer,
"_collect_online_restore_plugins",
AsyncMock(return_value=set()),
)
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
monkeypatch.setattr(plugins_initializer, "register_plugin_api", MagicMock())
monkeypatch.setattr(
+144 -1
View File
@@ -12,7 +12,11 @@ def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager:
"""构造使用隔离运行目录和事务目录的插件包管理器。"""
monkeypatch.setattr(
"app.adapters.system.plugin.package.settings",
SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"),
SimpleNamespace(
ROOT_PATH=tmp_path,
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
),
)
return PluginPackageManager(helper=Mock())
@@ -66,6 +70,145 @@ def test_rollback_does_not_delete_package_when_snapshot_is_missing(monkeypatch,
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "new"
def test_durable_checkpoint_stages_backup_without_overwriting_current_backup(
monkeypatch,
tmp_path,
):
"""数据库提交前只准备新备份,现有容器恢复材料保持可用。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
"app.adapters.system.plugin.package.SystemUtils.is_docker",
lambda: True,
)
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
plugin_dir.mkdir(parents=True)
backup_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
(backup_dir / "__init__.py").write_text("old", encoding="utf-8")
checkpoint = manager.checkpoint("DemoPlugin", "txn-1")
manager.stage_persistent_backup(checkpoint)
assert checkpoint.transaction_dir.parent == tmp_path / "config" / "plugin_transactions"
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old"
assert checkpoint.backup_staging_dir is not None
assert (checkpoint.backup_staging_dir / "__init__.py").read_text(
encoding="utf-8"
) == "new"
def test_activate_and_finalize_persistent_backup_are_retryable(monkeypatch, tmp_path):
"""备份激活保留旧载荷,数据库提交后的清理可以重复执行。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
"app.adapters.system.plugin.package.SystemUtils.is_docker",
lambda: True,
)
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
plugin_dir.mkdir(parents=True)
backup_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
(backup_dir / "__init__.py").write_text("old", encoding="utf-8")
checkpoint = manager.checkpoint("DemoPlugin", "txn-2")
manager.stage_persistent_backup(checkpoint)
manager.activate_persistent_backup(checkpoint)
manager.activate_persistent_backup(checkpoint)
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "new"
assert checkpoint.backup_staging_dir is not None
assert not checkpoint.backup_staging_dir.exists()
assert checkpoint.backup_previous_dir is not None
assert (checkpoint.backup_previous_dir / "__init__.py").read_text(
encoding="utf-8"
) == "old"
manager.finalize_persistent_backup(checkpoint)
manager.finalize_persistent_backup(checkpoint)
assert not checkpoint.backup_previous_dir.exists()
def test_rollback_removes_staging_but_preserves_current_backup(monkeypatch, tmp_path):
"""提交前失败只恢复运行目录,不修改上一份容器恢复备份。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
"app.adapters.system.plugin.package.SystemUtils.is_docker",
lambda: True,
)
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
plugin_dir.mkdir(parents=True)
backup_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8")
(backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8")
checkpoint = manager.checkpoint("DemoPlugin", "txn-3")
(plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8")
manager.stage_persistent_backup(checkpoint)
manager.rollback(checkpoint)
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime"
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup"
assert checkpoint.backup_staging_dir is not None
assert not checkpoint.backup_staging_dir.exists()
def test_rollback_after_backup_activation_restores_previous_backup(
monkeypatch,
tmp_path,
):
"""数据库提交前失败时,已激活的新备份必须回退到上一份载荷。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
"app.adapters.system.plugin.package.SystemUtils.is_docker",
lambda: True,
)
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
plugin_dir.mkdir(parents=True)
backup_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8")
(backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8")
checkpoint = manager.checkpoint("DemoPlugin", "txn-4")
(plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8")
manager.stage_persistent_backup(checkpoint)
manager.activate_persistent_backup(checkpoint)
manager.rollback(checkpoint)
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime"
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup"
def test_restore_checkpoint_derives_only_controlled_paths(monkeypatch, tmp_path):
"""崩溃回放只按事务 ID 在受控根目录内重建文件引用。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
"app.adapters.system.plugin.package.SystemUtils.is_docker",
lambda: True,
)
checkpoint = manager.restore_checkpoint(
plugin_id="DemoPlugin",
transaction_id="txn-5",
plugin_existed=True,
persistent_backup_existed=False,
)
assert checkpoint.transaction_dir == (
tmp_path / "config" / "plugin_transactions" / "txn-5"
)
assert checkpoint.backup_staging_dir == (
tmp_path / "config" / "plugins_backup" / ".demoplugin.staging-txn-5"
)
assert checkpoint.backup_previous_dir == (
tmp_path / "config" / "plugins_backup" / ".demoplugin.previous-txn-5"
)
def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path):
"""本地来源不可复制时不得丢失已经运行的插件副本。"""
manager = _manager(monkeypatch, tmp_path)
+6 -1
View File
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from app.application.plugin.lifecycle import PluginStartupLease
from app.runtime.config import global_vars
from app.startup import lifecycle
@@ -12,10 +13,12 @@ from app.startup import lifecycle
async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch) -> None:
"""插件 ready 只在调度任务和命令注册完成后对外可见。"""
order: list[str] = []
startup_tokens: list[PluginStartupLease] = []
manager = MagicMock()
command_future = Future()
async def sync_plugins() -> bool:
async def sync_plugins(startup_token: PluginStartupLease) -> bool:
startup_tokens.append(startup_token)
order.append("plugins")
return True
@@ -70,3 +73,5 @@ async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch
"settling:False",
"monitor",
]
assert len(startup_tokens) == 1
assert isinstance(startup_tokens[0], PluginStartupLease)
+299
View File
@@ -0,0 +1,299 @@
"""插件候选事实与来源选择策略测试。"""
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.source import (
CandidateInventory,
LocalCandidateRead,
MarketRead,
PluginLocalCandidate,
PluginMarketCandidate,
PluginSelectionStatus,
select_plugin_candidate,
)
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
OTHER_SOURCE = "github:other/moviepilot-plugins"
def _online(
source_key: str,
*,
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
version: str = "1.0.0",
generation: str = "v3",
plugin_id: str = "DemoPlugin",
repo_url: str = "https://github.com/example/moviepilot-plugins",
) -> PluginMarketCandidate:
"""构造测试用在线候选。"""
return PluginMarketCandidate(
plugin_id=plugin_id,
source_key=source_key,
source_type=source_type,
repo_url=repo_url,
package_generation=generation,
plugin_version=version,
dto={"id": plugin_id, "version": version},
)
def _inventory(*reads: MarketRead, local=()) -> CandidateInventory:
"""构造测试用候选快照。"""
return CandidateInventory(tuple(reads), tuple(local))
def _identity(source_type: TrustedPluginSourceType, source_key: str) -> PluginIdentity:
"""构造已绑定在线来源身份。"""
from datetime import datetime, timezone
now = datetime(2026, 8, 25, tzinfo=timezone.utc)
return PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=source_type,
trusted_source_key=source_key,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT
if source_type is TrustedPluginSourceType.OFFICIAL
else PluginBindingBasis.TOFU,
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
declared_version=None,
package_generation=None,
system_version=None,
supports_v3=None,
supports_v3t=None,
payload_receipt=None,
revision=1,
created_at=now,
updated_at=now,
bound_at=now,
payload_applied_at=None,
)
def test_cross_source_high_version_does_not_win() -> None:
"""已绑定来源过滤必须先于版本比较,跨源高版本不能覆盖允许来源。"""
inventory = _inventory(
MarketRead.present(
"market-a",
(
_online(THIRD_PARTY_SOURCE, version="1.0.0"),
_online(OTHER_SOURCE, version="9.0.0", repo_url="https://github.com/other/moviepilot-plugins"),
),
),
)
result = select_plugin_candidate(
inventory,
plugin_id="DemoPlugin",
identity=_identity(TrustedPluginSourceType.THIRD_PARTY, THIRD_PARTY_SOURCE),
generations=("v3", "v2", "v1"),
)
assert result.status is PluginSelectionStatus.SELECTED
assert result.candidate is not None
assert result.candidate.source_key == THIRD_PARTY_SOURCE
assert result.candidate.plugin_version == "1.0.0"
def test_same_source_prefers_generation_then_version() -> None:
"""同源候选先按运行代际,再在同代内按声明版本选择。"""
inventory = _inventory(
MarketRead.present(
"market-a",
(
_online(THIRD_PARTY_SOURCE, generation="v2", version="9.0.0"),
_online(THIRD_PARTY_SOURCE, generation="v3", version="1.0.0"),
_online(THIRD_PARTY_SOURCE, generation="v3", version="2.0.0"),
),
),
)
result = select_plugin_candidate(
inventory,
plugin_id="DemoPlugin",
identity=_identity(TrustedPluginSourceType.THIRD_PARTY, THIRD_PARTY_SOURCE),
generations=("v3", "v2", "v1"),
)
assert len(inventory.candidates_for("demoplugin")) == 3
assert result.candidate is not None
assert result.candidate.package_generation == "v3"
assert result.candidate.plugin_version == "2.0.0"
def test_partial_market_failure_blocks_unique_third_party_tofu() -> None:
"""部分市场失败时即使当前可见一个第三方,也不能证明其唯一。"""
inventory = _inventory(
MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),
MarketRead.failure("market-b", "timeout"),
)
result = select_plugin_candidate(
inventory,
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
)
assert inventory.complete is False
assert inventory.can_use_for_tofu is False
assert result.status is PluginSelectionStatus.INCOMPLETE
def test_partial_inventory_expectations_never_authorize_tofu() -> None:
"""缺少任一预期维度时,快照不能证明第三方来源唯一。"""
reads = (MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),)
markets_only = CandidateInventory(
reads,
expected_markets=("market-a", "market-b"),
)
generations_only = CandidateInventory(
reads,
expected_generations=("v3",),
)
assert markets_only.complete is False
assert markets_only.can_use_for_tofu is False
assert generations_only.complete is False
assert generations_only.can_use_for_tofu is False
def test_local_scan_failure_blocks_automatic_selection_but_explicit_source_continues() -> None:
"""本地扫描失败时自动路径闭锁,管理员明确选在线来源仍可继续。"""
inventory = CandidateInventory(
(
MarketRead.present(
"market-a",
(_online(THIRD_PARTY_SOURCE),),
),
),
local_read=LocalCandidateRead.failure("local repository unavailable"),
)
automatic = select_plugin_candidate(
inventory,
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
)
explicit = select_plugin_candidate(
inventory,
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
requested_source_key=THIRD_PARTY_SOURCE,
explicit_source=True,
)
assert automatic.status is PluginSelectionStatus.INCOMPLETE
assert explicit.status is PluginSelectionStatus.SELECTED
assert explicit.candidate is not None
assert explicit.candidate.source_key == THIRD_PARTY_SOURCE
def test_non_explicit_source_hint_cannot_bypass_local_state() -> None:
"""兼容来源参数不能替换本地载荷,也不能绕过本地读取失败闭锁。"""
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins",
package_generation="v3",
plugin_version="2.0.0-dev",
)
identity = _identity(
TrustedPluginSourceType.THIRD_PARTY,
THIRD_PARTY_SOURCE,
)
with_local = select_plugin_candidate(
_inventory(
MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),
local=(local,),
),
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
identity=identity,
requested_source_key=THIRD_PARTY_SOURCE,
explicit_source=False,
)
failed_local_read = select_plugin_candidate(
CandidateInventory(
(MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),),
local_read=LocalCandidateRead.failure("local repository unavailable"),
),
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
identity=identity,
requested_source_key=THIRD_PARTY_SOURCE,
explicit_source=False,
)
assert with_local.status is PluginSelectionStatus.SELECTED
assert with_local.candidate is local
assert failed_local_read.status is PluginSelectionStatus.INCOMPLETE
def test_uninstalled_unique_and_multiple_sources_are_distinct() -> None:
"""未安装插件允许完整快照中的唯一来源,多来源必须返回冲突。"""
unique = select_plugin_candidate(
_inventory(MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),))),
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
)
conflict = select_plugin_candidate(
_inventory(
MarketRead.present(
"market-a",
(_online(THIRD_PARTY_SOURCE), _online(OTHER_SOURCE)),
),
),
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
)
assert unique.status is PluginSelectionStatus.SELECTED
assert conflict.status is PluginSelectionStatus.CONFLICT
assert set(conflict.conflict_source_keys) == {THIRD_PARTY_SOURCE, OTHER_SOURCE}
def test_official_candidate_is_selectable_and_local_projection_hides_path() -> None:
"""官方来源可正常选择,本地公共投影不能泄漏仓库路径或 metadata。"""
official = select_plugin_candidate(
_inventory(
MarketRead.present(
"official-market",
(_online(
OFFICIAL_SOURCE,
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url="https://github.com/jxxghp/moviepilot-plugins",
),),
),
),
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
)
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/secret/plugins",
package_generation="v3",
plugin_version="3.0.0",
dto={"path": "/private/secret/plugins"},
)
local_result = select_plugin_candidate(
_inventory(MarketRead.present("official-market", ()), local=(local,)),
plugin_id="DemoPlugin",
generations=("v3", "v2", "v1"),
)
assert official.status is PluginSelectionStatus.SELECTED
assert official.candidate is not None
assert official.candidate.source_type is TrustedPluginSourceType.OFFICIAL
assert local.payload_source_type is PluginPayloadSourceType.LOCAL
assert local.source_type is PluginPayloadSourceType.LOCAL
assert local.source_key is None
assert local_result.candidate is local
public = local_result.public_dict()
assert "/private/secret/plugins" not in str(public)
assert "repo_url" not in public["candidate"]
+207 -3
View File
@@ -1,9 +1,31 @@
"""插件市场同步服务用例。"""
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import Mock
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.plugin.gateway import PluginInstallGateway
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.install import PluginInstallResult
from app.application.plugin.lifecycle import plugin_lifecycle
from app.application.plugin.source import (
CandidateInventory,
MarketRead,
PluginMarketCandidate,
)
from app.runtime.config import global_vars
from app.runtime.extensions.plugin.sync import PluginSyncService
from app.startup.initializers import plugins as plugins_initializer
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
def test_market_sync_keeps_install_rollback_enabled() -> None:
@@ -24,9 +46,191 @@ def test_market_sync_keeps_install_rollback_enabled() -> None:
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=install,
report=Mock(),
log=Mock(),
)
assert service.sync() == [plugin.id]
install.assert_called_once_with(plugin.id, plugin.repo_url, False)
install.assert_called_once_with(plugin.id, None, False, None)
def test_market_sync_restores_trusted_online_payload_after_local_source_removed() -> None:
"""本地高版本来源消失后,启动同步仍恢复已绑定的在线载荷。"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.2.0",
system_version_compatible=False,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
assert service.sync(
online_restore_plugins={"demoplugin"},
) == [plugin.id]
install.assert_called_once_with(plugin.id, None, False, None)
def test_market_sync_keeps_active_local_payload_when_candidate_still_exists() -> None:
"""本地候选仍存在时,不应被启动在线恢复覆盖。"""
online = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.2.0",
system_version_compatible=True,
)
local = SimpleNamespace(
id="DemoPlugin",
repo_url="local://DemoPlugin?package_version=v3",
plugin_name="Demo Local",
plugin_version="9.9.10",
system_version_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [online.id],
online_plugins=lambda: [online],
local_plugins=lambda: [local],
merge_plugins=lambda items, *_args: [online],
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
assert service.sync(online_restore_plugins={"demoplugin"}) == []
install.assert_not_called()
@pytest.mark.asyncio
async def test_market_sync_reuses_startup_lease_through_real_gateway(
monkeypatch,
) -> None:
"""启动自动安装跨线程进入 Gateway 时必须复用同一个 startup lease。"""
competing_repo_url = "https://github.com/example/MoviePilot-Plugins"
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=competing_repo_url,
plugin_name="Demo",
plugin_version="9.0.0",
system_version_compatible=True,
)
official_candidate = PluginMarketCandidate(
plugin_id=plugin.id,
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.1.0",
dto={"v3": True},
)
competing_candidate = PluginMarketCandidate(
plugin_id=plugin.id,
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url=competing_repo_url,
package_generation="v3",
plugin_version=plugin.plugin_version,
dto={"v3": True},
)
inventory = CandidateInventory((
MarketRead.present(
REPO_URL,
(official_candidate,),
package_generation="v3",
),
MarketRead.present(
competing_repo_url,
(competing_candidate,),
package_generation="v3",
),
))
identity = PluginIdentity(
plugin_id=plugin.id,
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="9.9.10",
package_generation="v3",
system_version=None,
supports_v3=True,
supports_v3t=None,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
executor = AsyncMock()
executor.execute.return_value = PluginInstallResult(success=True)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
monkeypatch.setattr(
global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
def install(
plugin_id: str,
repo_url: str | None,
force: bool,
startup_token: object | None,
) -> tuple[bool, str]:
"""复用生产同步包装层,把线程池安装提交回宿主事件循环。"""
return plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id=plugin_id,
repo_url=repo_url,
package_version="v3",
release_version=None,
force=force,
local_sync=False,
explicit_source=False,
startup_token=startup_token,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
async with plugin_lifecycle.hold_startup() as startup_token:
synced = await asyncio.wait_for(
asyncio.to_thread(
service.sync,
startup_token,
online_restore_plugins={"demoplugin"},
),
timeout=2,
)
assert synced == [plugin.id]
executor.execute.assert_awaited_once()
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate.repo_url == REPO_URL
+36
View File
@@ -117,6 +117,42 @@ def test_failed_write_keeps_committed_snapshot(monkeypatch):
assert oper.get(key) == "old"
def test_update_atomically_commits_related_records_and_snapshot() -> None:
"""关联记录与最终配置值必须在同一事务成功后一起可见。"""
key = _unique_key()
related_key = _unique_key()
oper = _fresh_oper()
oper.set(key, ["ExistingPlugin"])
def mutation(session, current):
session.add(SystemConfig(key=related_key, value={"phase": "committed"}))
return "done", [*current, "DemoPlugin"]
assert oper.update_atomically(key, mutation) == "done"
assert oper.get(key) == ["ExistingPlugin", "DemoPlugin"]
assert _stored_config(key).value == ["ExistingPlugin", "DemoPlugin"]
assert _stored_config(related_key).value == {"phase": "committed"}
def test_update_atomically_keeps_snapshot_when_related_write_fails() -> None:
"""关联写失败时配置数据库值和内存快照都保持最近提交状态。"""
key = _unique_key()
related_key = _unique_key()
oper = _fresh_oper()
oper.set(key, ["ExistingPlugin"])
def mutation(session, _current):
session.add(SystemConfig(key=related_key, value=True))
raise RuntimeError("related write failed")
with pytest.raises(RuntimeError, match="related write failed"):
oper.update_atomically(key, mutation)
assert oper.get(key) == ["ExistingPlugin"]
assert _stored_config(key).value == ["ExistingPlugin"]
assert _stored_config(related_key) is None
def test_increment_serializes_concurrent_counter_updates(monkeypatch):
"""并发递增系统计数时不应丢失更新。"""
oper = object.__new__(SystemConfigOper)