diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index e846096ec..7c1b4608e 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -28,6 +28,7 @@ from app.api.principal import ApiPrincipal from app.api.response import ResponseAPIRouter from app.application.commands import init_commands from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config +from app.application.plugin.catalog import apply_declared_metadata_fallback from app.application.plugin.config import PluginConfigCommand from app.application.plugin.folders import remove_plugin_from_folders from app.application.plugin.gateway import get_plugin_install_service @@ -234,14 +235,21 @@ async def _get_plugin_history_detail( installed_plugin = next( ( plugin - for plugin in plugin_manager.get_local_plugins() - if plugin.id == plugin_id and plugin.installed + for plugin in plugin_manager.get_installed_plugins() + if plugin.id == plugin_id ), None, ) if not installed_plugin: return None + identity = await get_plugin_persistence().get_identity(plugin_id) + if identity is not None: + installed_plugin = apply_declared_metadata_fallback( + [installed_plugin], + {identity.normalized_plugin_id: identity}, + )[0] + local_repo_plugin = next( (plugin for plugin in plugin_manager.get_local_repo_plugins() if plugin.id == plugin_id), None, @@ -249,27 +257,35 @@ async def _get_plugin_history_detail( if local_repo_plugin: return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin) - if installed_plugin.repo_url: + trusted_repo_url = None + if identity is not None and identity.trusted_source_key: + owner_repo = identity.trusted_source_key.removeprefix("github:") + trusted_repo_url = f"https://github.com/{owner_repo}" + + if trusted_repo_url: market_plugin = await _get_market_plugin_from_repo( - plugin_manager, plugin_id, installed_plugin.repo_url, force + plugin_manager, plugin_id, trusted_repo_url, force ) if not market_plugin: logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息") return installed_plugin return _merge_plugin_market_metadata(installed_plugin, market_plugin) - market_plugin = next( - ( - plugin - for plugin in await plugin_manager.async_get_online_plugins(force=force) - if plugin.id == plugin_id - ), - None, - ) - if not market_plugin: - return installed_plugin + return installed_plugin - return _merge_plugin_market_metadata(installed_plugin, market_plugin) + +async def _installed_plugins_with_declared_metadata( + plugin_manager: PluginRuntime, +) -> list[_SchemaPlugin]: + """批量读取身份,并为未加载插件补齐已提交的展示声明。""" + plugins = plugin_manager.get_installed_plugins() + identities = await get_plugin_persistence().list_identities( + [plugin.id for plugin in plugins if plugin.id] + ) + return apply_declared_metadata_fallback( + plugins, + {identity.normalized_plugin_id: identity for identity in identities}, + ) @router.get("/", summary="所有插件", response_model=List[_SchemaPlugin]) @@ -281,14 +297,13 @@ async def all_plugins( """ 查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all """ - # 本地插件 plugin_manager = get_plugin_manager() - local_plugins = plugin_manager.get_local_plugins() - # 已安装插件 - installed_plugins = [plugin for plugin in local_plugins if plugin.installed] + installed_plugins = plugin_manager.get_installed_plugins() if state == "installed": - return plugin_manager.get_installed_plugins() + return await _installed_plugins_with_declared_metadata(plugin_manager) + # 本地插件 + local_plugins = plugin_manager.get_local_plugins() # 未安装的本地插件 not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed] # 本地插件仓库目录中的插件 @@ -305,7 +320,10 @@ async def all_plugins( if state == "market": # 返回未安装的本地插件 return not_installed_plugins - return local_plugins + return ( + await _installed_plugins_with_declared_metadata(plugin_manager) + + not_installed_plugins + ) # 插件市场插件清单 market_plugins = [] @@ -328,7 +346,10 @@ async def all_plugins( return market_plugins # 返回所有插件 - return installed_plugins + market_plugins + return ( + await _installed_plugins_with_declared_metadata(plugin_manager) + + market_plugins + ) @router.get("/installed", summary="已安装插件", response_model=List[str]) diff --git a/app/application/plugin/admission.py b/app/application/plugin/admission.py index 12f7d9f85..8cd4fbd47 100644 --- a/app/application/plugin/admission.py +++ b/app/application/plugin/admission.py @@ -6,6 +6,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -60,14 +61,13 @@ class PluginInstallAdmission: payload_receipt: str, applied_at: datetime, declared_version: str | None = None, + manifest_matches_payload: bool = True, ) -> PluginIdentity: """在载荷落盘并生成收据后构造唯一数据库提交目标。""" current = self.identity_before plugin_id = current.plugin_id if current else self.candidate.plugin_id metadata = self.candidate.dto if isinstance(self.candidate.dto, Mapping) else {} - system_version = metadata.get("system_version") - supports_v3 = metadata.get("v3") - supports_v3t = metadata.get("v3t") + installed_version = declared_version or self.candidate.plugin_version source_binding_changed = ( self.trusted_source_type is not TrustedPluginSourceType.UNKNOWN and ( @@ -89,13 +89,13 @@ class PluginInstallAdmission: if isinstance(self.candidate, PluginLocalCandidate) else self.candidate.source_key ), - declared_version=declared_version or self.candidate.plugin_version, + declared_version=installed_version, package_generation=self.candidate.package_generation, - system_version=( - system_version if isinstance(system_version, str) else None + declared_metadata=PluginDeclaredMetadata.from_package( + metadata, + declaration_version=self.candidate.plugin_version, + manifest_matches_payload=manifest_matches_payload, ), - supports_v3=supports_v3 if isinstance(supports_v3, bool) else None, - supports_v3t=supports_v3t if isinstance(supports_v3t, bool) else None, payload_receipt=payload_receipt, revision=(current.revision + 1) if current else 1, created_at=current.created_at if current else applied_at, diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 537ba94ab..2c2005093 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -4,9 +4,11 @@ from __future__ import annotations import asyncio import concurrent.futures -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import Any, Optional +from app.application.plugin.identity import PluginIdentity +from app.schemas.plugin import Plugin MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]] AsyncMarketLoader = Callable[ @@ -17,6 +19,44 @@ PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any] ProgressCallback = Callable[..., Any] +def apply_declared_metadata_fallback( + plugins: Sequence[Plugin], + identities: Mapping[str, PluginIdentity], +) -> list[Plugin]: + """用已提交快照补齐加载失败插件,不覆盖真实运行态字段。""" + result: list[Plugin] = [] + for plugin in plugins: + identity = identities.get((plugin.id or "").lower()) + if ( + identity is None + or identity.declared_metadata is None + or identity.declared_version is None + ): + result.append(plugin) + continue + fallback = identity.declared_metadata.display_fallback( + installed_version=identity.declared_version + ) + updates: dict[str, str] = {} + if not plugin.plugin_version: + updates["plugin_version"] = fallback["plugin_version"] + if ( + (not plugin.plugin_name or plugin.plugin_name == plugin.id) + and "plugin_name" in fallback + ): + updates["plugin_name"] = fallback["plugin_name"] + if not plugin.plugin_desc and "plugin_desc" in fallback: + updates["plugin_desc"] = fallback["plugin_desc"] + if not plugin.plugin_icon and "plugin_icon" in fallback: + updates["plugin_icon"] = fallback["plugin_icon"] + if not plugin.plugin_author and "plugin_author" in fallback: + updates["plugin_author"] = fallback["plugin_author"] + if not plugin.plugin_label and "plugin_label" in fallback: + updates["plugin_label"] = fallback["plugin_label"] + result.append(plugin.model_copy(update=updates) if updates else plugin) + return result + + class PluginCatalogService: """负责插件市场索引映射、并发收集、代际合并和来源去重。""" diff --git a/app/application/plugin/declaration.py b/app/application/plugin/declaration.py new file mode 100644 index 000000000..8379975a7 --- /dev/null +++ b/app/application/plugin/declaration.py @@ -0,0 +1,207 @@ +"""已提交插件载荷的版本化 package 声明快照。""" + +from __future__ import annotations + +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +_SCHEMA_VERSION = 1 +_RUNTIME_FIELD_PATTERN = re.compile(r"^v[1-9][0-9]*t?$") +_MANIFEST_TEXT_FIELDS = ( + "name", + "description", + "icon", + "author", + "system_version", +) + + +def _optional_text( + value: object, + *, + max_length: int | None = None, +) -> str | None: + """把外部可选文本规范为非空字符串。""" + if not isinstance(value, str): + return None + normalized = value.strip() + if max_length is not None and len(normalized) > max_length: + raise ValueError(f"插件声明文本长度不能超过 {max_length}") + return normalized or None + + +def _labels(value: object) -> list[str]: + """把历史逗号字符串和字符串数组统一为有序标签列表。""" + if isinstance(value, str): + items = value.split(",") + elif isinstance(value, list): + items = value + else: + return [] + return [ + normalized + for item in items + if isinstance(item, str) and (normalized := item.strip()) + ] + + +def _normalize_manifest(value: object) -> dict[str, object]: + """仅保留当前载荷展示和声明消费者需要的 package 字段。""" + if not isinstance(value, Mapping): + return {} + manifest: dict[str, object] = {} + for field_name in _MANIFEST_TEXT_FIELDS: + if normalized := _optional_text( + value.get(field_name), + max_length=128 if field_name == "system_version" else None, + ): + manifest[field_name] = normalized + labels = _labels(value.get("labels")) + if labels: + manifest["labels"] = labels + level = value.get("level") + if isinstance(level, int) and not isinstance(level, bool): + manifest["level"] = level + release = value.get("release") + if isinstance(release, bool): + manifest["release"] = release + return manifest + + +def _normalize_runtime(value: object) -> dict[str, bool]: + """保留可向后扩展的宿主代际和运行时变体布尔声明。""" + if not isinstance(value, Mapping): + return {} + return { + str(field_name): field_value + for field_name, field_value in value.items() + if ( + isinstance(field_name, str) + and _RUNTIME_FIELD_PATTERN.fullmatch(field_name) + and isinstance(field_value, bool) + ) + } + + +def _encode(value: Mapping[str, object]) -> str: + """生成不可从外部原地修改的稳定内部表示。""" + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +@dataclass(frozen=True, slots=True) +class PluginDeclaredMetadata: + """封装一份受限、版本化且不参与来源授信的 package 声明。""" + + _encoded: str = field(repr=False) + + def __post_init__(self) -> None: + """拒绝绕过工厂构造的非规范内部状态。""" + normalized = self._normalize_storage(json.loads(self._encoded)) + object.__setattr__(self, "_encoded", _encode(normalized)) + + @classmethod + def from_package( + cls, + package: Mapping[str, Any], + *, + declaration_version: str | None, + manifest_matches_payload: bool, + ) -> "PluginDeclaredMetadata": + """从安装候选生成与载荷一起提交的受限声明快照。""" + runtime = _normalize_runtime(package) + value = { + "schema_version": _SCHEMA_VERSION, + "declaration_version": _optional_text(declaration_version), + "manifest_matches_payload": manifest_matches_payload, + "manifest": _normalize_manifest(package), + "runtime": runtime, + } + return cls(_encode(value)) + + @classmethod + def from_storage(cls, value: object) -> "PluginDeclaredMetadata": + """校验数据库 JSON,并忽略当前合同未消费的额外字段。""" + return cls(_encode(cls._normalize_storage(value))) + + @staticmethod + def _normalize_storage(value: object) -> dict[str, object]: + """验证快照结构,同时宽容丢弃非法可选展示字段。""" + if not isinstance(value, Mapping): + raise ValueError("插件声明快照必须是 JSON 对象") + if value.get("schema_version") != _SCHEMA_VERSION: + raise ValueError("插件声明快照 schema_version 不受支持") + manifest_matches_payload = value.get("manifest_matches_payload") + if not isinstance(manifest_matches_payload, bool): + raise ValueError("插件声明快照必须说明 manifest 是否对应当前载荷") + return { + "schema_version": _SCHEMA_VERSION, + "declaration_version": _optional_text( + value.get("declaration_version") + ), + "manifest_matches_payload": manifest_matches_payload, + "manifest": _normalize_manifest(value.get("manifest")), + "runtime": _normalize_runtime(value.get("runtime")), + } + + @property + def declaration_version(self) -> str | None: + """返回生成该快照的 package 条目版本。""" + return _optional_text(self.to_json().get("declaration_version")) + + @property + def manifest_matches_payload(self) -> bool: + """说明该 package 声明是否与当前已提交载荷对应。""" + return bool(self.to_json()["manifest_matches_payload"]) + + def runtime_support(self, runtime_name: str) -> bool | None: + """读取一个规范运行时声明;缺省保持未声明语义。""" + if not _RUNTIME_FIELD_PATTERN.fullmatch(runtime_name): + raise ValueError("运行时声明必须使用 v<数字> 或 v<数字>t") + runtime = self.to_json().get("runtime") + if not isinstance(runtime, dict): + return None + value = runtime.get(runtime_name) + return value if isinstance(value, bool) else None + + def display_fallback(self, *, installed_version: str) -> dict[str, str]: + """生成插件加载失败时可安全补齐的展示字段。""" + manifest = self.to_json().get("manifest") + fallback = { + "plugin_version": installed_version, + } + if not isinstance(manifest, dict): + return fallback + display_fields = { + "name": "plugin_name", + "description": "plugin_desc", + "icon": "plugin_icon", + "author": "plugin_author", + } + for source_name, target_name in display_fields.items(): + value = manifest.get(source_name) + if isinstance(value, str): + fallback[target_name] = value + labels = manifest.get("labels") + if isinstance(labels, list): + label = ",".join(item for item in labels if isinstance(item, str)) + if label: + fallback["plugin_label"] = label + return fallback + + def to_json(self) -> dict[str, object]: + """返回可持久化且与内部状态相互隔离的 JSON 副本。""" + value: object = json.loads(self._encoded) + if not isinstance(value, dict): + raise ValueError("插件声明快照内部状态必须是 JSON 对象") + return { + str(field_name): field_value + for field_name, field_value in value.items() + } diff --git a/app/application/plugin/identity.py b/app/application/plugin/identity.py index 637665773..12908382a 100644 --- a/app/application/plugin/identity.py +++ b/app/application/plugin/identity.py @@ -3,12 +3,14 @@ from __future__ import annotations import re -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from datetime import datetime from enum import StrEnum from typing import Protocol +from app.application.plugin.declaration import PluginDeclaredMetadata + _PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,127}$") _ONLINE_SOURCE_KEY_PATTERN = re.compile( r"^github:[a-z0-9](?:[a-z0-9-]{0,38})/" @@ -116,9 +118,7 @@ class PluginIdentity: payload_source_key: str | None declared_version: str | None package_generation: str | None - system_version: str | None - supports_v3: bool | None - supports_v3t: bool | None + declared_metadata: PluginDeclaredMetadata | None payload_receipt: str | None revision: int created_at: datetime @@ -200,9 +200,7 @@ class PluginIdentity: if any(( self.declared_version, self.package_generation, - self.system_version, - self.supports_v3 is not None, - self.supports_v3t is not None, + self.declared_metadata, self.payload_receipt, self.payload_applied_at, )): @@ -210,6 +208,8 @@ class PluginIdentity: else: if not self.declared_version or not self.package_generation: raise ValueError("已知载荷必须携带声明版本和包代际") + if self.declared_metadata is None: + raise ValueError("已知载荷必须携带已提交 package 声明快照") if self.payload_applied_at is None or self.payload_receipt is None: raise ValueError("已知载荷必须携带应用时间和内容收据") if ( @@ -234,11 +234,6 @@ class PluginIdentity: field_name="插件声明版本", max_length=64, ) - _validate_optional_text( - self.system_version, - field_name="插件系统版本要求", - max_length=128, - ) object.__setattr__(self, "trusted_source_key", trusted_key) object.__setattr__(self, "payload_source_key", payload_key) object.__setattr__(self, "payload_receipt", _validate_receipt(self.payload_receipt)) @@ -320,9 +315,7 @@ def plan_legacy_plugin_identity( payload_source_key=None, declared_version=None, package_generation=None, - system_version=None, - supports_v3=None, - supports_v3t=None, + declared_metadata=None, payload_receipt=None, revision=1, created_at=now, @@ -356,6 +349,9 @@ class PluginIdentityStore(Protocol): def get(self, plugin_id: str) -> PluginIdentity | None: """读取一个物理插件的来源身份。""" + def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]: + """批量读取指定物理插件的来源身份。""" + def compare_and_set( self, identity: PluginIdentity, diff --git a/app/application/plugin/install.py b/app/application/plugin/install.py index 4f7d3206e..b663ec88c 100644 --- a/app/application/plugin/install.py +++ b/app/application/plugin/install.py @@ -356,6 +356,10 @@ class PluginInstallCommand: payload_receipt=receipt, applied_at=self.__clock(), declared_version=release_version, + manifest_matches_payload=( + release_version is None + or release_version == candidate.plugin_version + ), ) await self.__await_side_effect( self.__persistence.set_installation_target( diff --git a/app/application/plugin/transaction.py b/app/application/plugin/transaction.py index bb93fd0d4..a0055795d 100644 --- a/app/application/plugin/transaction.py +++ b/app/application/plugin/transaction.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime from enum import StrEnum @@ -166,6 +167,9 @@ class PluginIdentityPersistence(Protocol): def get(self, plugin_id: str) -> PluginIdentity | None: """读取一个物理插件的来源身份。""" + def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]: + """批量读取指定物理插件的来源身份。""" + def compare_and_set( self, identity: PluginIdentity, @@ -202,6 +206,15 @@ class PluginPersistenceService: """在数据库 worker 中读取插件来源身份。""" return await self.__executor.run(partial(self.__identities.get, plugin_id)) + async def list_identities( + self, + plugin_ids: Sequence[str], + ) -> list[PluginIdentity]: + """在一次数据库任务中批量读取插件来源身份。""" + return await self.__executor.run( + partial(self.__identities.list, tuple(plugin_ids)) + ) + async def migrate_identity( self, identity: PluginIdentity, diff --git a/app/db/adapters/pluginidentity.py b/app/db/adapters/pluginidentity.py index bcda8ed9c..d34ef9d1f 100644 --- a/app/db/adapters/pluginidentity.py +++ b/app/db/adapters/pluginidentity.py @@ -1,11 +1,12 @@ """插件来源身份 Application Port 的 SQLAlchemy 实现。""" -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( BindLocalPluginIdentityCommand, BindOnlinePluginIdentityCommand, @@ -40,9 +41,11 @@ def _to_record(model: IdentityModel) -> PluginIdentity: payload_source_key=model.payload_source_key, declared_version=model.declared_version, package_generation=model.package_generation, - system_version=model.system_version, - supports_v3=model.supports_v3, - supports_v3t=model.supports_v3t, + declared_metadata=( + PluginDeclaredMetadata.from_storage(model.declared_metadata) + if model.declared_metadata is not None + else None + ), payload_receipt=model.payload_receipt, revision=model.revision, created_at=datetime.fromisoformat(model.created_at), @@ -64,9 +67,11 @@ def _to_model(identity: PluginIdentity) -> IdentityModel: payload_source_key=identity.payload_source_key, declared_version=identity.declared_version, package_generation=identity.package_generation, - system_version=identity.system_version, - supports_v3=identity.supports_v3, - supports_v3t=identity.supports_v3t, + declared_metadata=( + identity.declared_metadata.to_json() + if identity.declared_metadata is not None + else None + ), payload_receipt=identity.payload_receipt, revision=identity.revision, created_at=identity.created_at.isoformat(), @@ -92,6 +97,13 @@ class _SqlAlchemyIdentityRepository: model = self._oper.get_by_plugin_id(plugin_id) return _to_record(model) if model else None + def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]: + """批量读取并映射指定来源身份。""" + return [ + _to_record(model) + for model in self._oper.list_by_plugin_ids(plugin_ids) + ] + def stage_create(self, identity: PluginIdentity) -> None: """暂存首次身份。""" try: @@ -131,6 +143,20 @@ class TransactionalPluginIdentityStore: finally: session.close() + def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]: + """在一个短会话内批量读取规范化插件身份。""" + normalized_ids = tuple( + dict.fromkeys( + normalize_physical_plugin_id(plugin_id) + for plugin_id in plugin_ids + ) + ) + session = self._session_factory() + try: + return _SqlAlchemyIdentityRepository(session).list(normalized_ids) + finally: + session.close() + def compare_and_set( self, identity: PluginIdentity, diff --git a/app/db/adapters/plugininstallation.py b/app/db/adapters/plugininstallation.py index 780163b8e..67f5c621a 100644 --- a/app/db/adapters/plugininstallation.py +++ b/app/db/adapters/plugininstallation.py @@ -10,6 +10,7 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -50,9 +51,11 @@ def _identity_from_model(model: IdentityModel) -> PluginIdentity: payload_source_key=model.payload_source_key, declared_version=model.declared_version, package_generation=model.package_generation, - system_version=model.system_version, - supports_v3=model.supports_v3, - supports_v3t=model.supports_v3t, + declared_metadata=( + PluginDeclaredMetadata.from_storage(model.declared_metadata) + if model.declared_metadata is not None + else None + ), payload_receipt=model.payload_receipt, revision=model.revision, created_at=datetime.fromisoformat(model.created_at), @@ -82,9 +85,11 @@ def _identity_model_values(identity: PluginIdentity) -> dict[str, object]: "payload_source_key": identity.payload_source_key, "declared_version": identity.declared_version, "package_generation": identity.package_generation, - "system_version": identity.system_version, - "supports_v3": identity.supports_v3, - "supports_v3t": identity.supports_v3t, + "declared_metadata": ( + identity.declared_metadata.to_json() + if identity.declared_metadata is not None + else None + ), "payload_receipt": identity.payload_receipt, "revision": identity.revision, "created_at": identity.created_at.isoformat(), diff --git a/app/db/models/pluginidentity.py b/app/db/models/pluginidentity.py index c175f6f4b..0fe84165d 100644 --- a/app/db/models/pluginidentity.py +++ b/app/db/models/pluginidentity.py @@ -1,8 +1,8 @@ """已安装物理插件来源身份模型。""" -from typing import Optional +from typing import Any, Optional -from sqlalchemy import Boolean, CheckConstraint, Integer, String, UniqueConstraint +from sqlalchemy import JSON, CheckConstraint, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, get_id_column @@ -21,9 +21,9 @@ class PluginIdentity(Base): payload_source_key: Mapped[Optional[str]] = mapped_column(String(255)) declared_version: Mapped[Optional[str]] = mapped_column(String(64)) package_generation: Mapped[Optional[str]] = mapped_column(String(8)) - system_version: Mapped[Optional[str]] = mapped_column(String(128)) - supports_v3: Mapped[Optional[bool]] = mapped_column(Boolean) - supports_v3t: Mapped[Optional[bool]] = mapped_column(Boolean) + declared_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSON(none_as_null=True) + ) payload_receipt: Mapped[Optional[str]] = mapped_column(String(71)) revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) created_at: Mapped[str] = mapped_column(String(40), nullable=False) diff --git a/app/db/oper/pluginidentity.py b/app/db/oper/pluginidentity.py index afd02c193..37ae5d787 100644 --- a/app/db/oper/pluginidentity.py +++ b/app/db/oper/pluginidentity.py @@ -1,5 +1,6 @@ """插件来源身份的数据访问原语。""" +from collections.abc import Sequence from typing import cast from sqlalchemy import select, update @@ -25,6 +26,23 @@ class PluginIdentityOper(DbOper): ) ) + def list_by_plugin_ids( + self, + plugin_ids: Sequence[str], + ) -> list[PluginIdentity]: + """批量读取规范化物理插件 ID 对应的身份。""" + if not plugin_ids: + return [] + return list( + self._execute_sync_query( + lambda session: session.execute( + select(PluginIdentity).where( + PluginIdentity.normalized_plugin_id.in_(plugin_ids) + ) + ).scalars() + ) + ) + def stage_create(self, identity: PluginIdentity) -> None: """暂存首次身份并立即暴露数据库唯一键竞争。""" def stage(session: Session) -> None: diff --git a/database/versions/5f2a9c1e7b4d_3_0_12.py b/database/versions/5f2a9c1e7b4d_3_0_12.py new file mode 100644 index 000000000..9c0f3102c --- /dev/null +++ b/database/versions/5f2a9c1e7b4d_3_0_12.py @@ -0,0 +1,185 @@ +"""3.0.12 consolidate committed plugin package declarations. + +Revision ID: 5f2a9c1e7b4d +Revises: a6c8e2f4b1d3 +Create Date: 2026-08-26 +""" + +from collections.abc import Mapping + +import sqlalchemy as sa +from alembic import op + +revision = "5f2a9c1e7b4d" +down_revision = "a6c8e2f4b1d3" +branch_labels = None +depends_on = None + +_LEGACY_COLUMNS = { + "system_version", + "supports_v3", + "supports_v3t", +} + + +def _column_names() -> set[str]: + """返回当前插件身份表字段集合。""" + inspector = sa.inspect(op.get_bind()) + if "pluginidentity" not in inspector.get_table_names(): + return set() + return { + column["name"] + for column in inspector.get_columns("pluginidentity") + } + + +def _legacy_snapshot(row: Mapping[str, object]) -> dict[str, object] | None: + """把旧固定声明列保守回填为仅供展示的版本化快照。""" + if row["payload_source_type"] == "unknown": + return None + manifest: dict[str, object] = {} + system_version = row.get("system_version") + if isinstance(system_version, str) and system_version.strip(): + manifest["system_version"] = system_version.strip() + runtime = { + field_name.removeprefix("supports_"): row[field_name] + for field_name in ("supports_v3", "supports_v3t") + if isinstance(row.get(field_name), bool) + } + return { + "schema_version": 1, + "declaration_version": None, + "manifest_matches_payload": False, + "manifest": manifest, + "runtime": runtime, + } + + +def _backfill_declared_metadata() -> None: + """只为仍有旧载荷事实且尚无快照的身份生成保守声明。""" + identity = sa.table( + "pluginidentity", + sa.column("id", sa.Integer()), + sa.column("payload_source_type", sa.String()), + sa.column("system_version", sa.String()), + sa.column("supports_v3", sa.Boolean()), + sa.column("supports_v3t", sa.Boolean()), + sa.column("declared_metadata", sa.JSON(none_as_null=True)), + ) + connection = op.get_bind() + rows = connection.execute( + sa.select( + identity.c.id, + identity.c.payload_source_type, + identity.c.system_version, + identity.c.supports_v3, + identity.c.supports_v3t, + ).where(identity.c.declared_metadata.is_(None)) + ).mappings().all() + for row in rows: + connection.execute( + identity.update() + .where(identity.c.id == row["id"]) + .values(declared_metadata=_legacy_snapshot(row)) + ) + + +def _legacy_values(value: object) -> dict[str, object]: + """从版本化快照恢复旧版可表达的三个固定声明字段。""" + if not isinstance(value, Mapping) or value.get("schema_version") != 1: + return { + "system_version": None, + "supports_v3": None, + "supports_v3t": None, + } + manifest = value.get("manifest") + runtime = value.get("runtime") + return { + "system_version": ( + manifest.get("system_version") + if isinstance(manifest, Mapping) + and isinstance(manifest.get("system_version"), str) + else None + ), + "supports_v3": ( + runtime.get("v3") + if isinstance(runtime, Mapping) + and isinstance(runtime.get("v3"), bool) + else None + ), + "supports_v3t": ( + runtime.get("v3t") + if isinstance(runtime, Mapping) + and isinstance(runtime.get("v3t"), bool) + else None + ), + } + + +def _restore_legacy_columns() -> None: + """在降级前把当前快照投影回旧版固定声明列。""" + identity = sa.table( + "pluginidentity", + sa.column("id", sa.Integer()), + sa.column("declared_metadata", sa.JSON(none_as_null=True)), + sa.column("system_version", sa.String()), + sa.column("supports_v3", sa.Boolean()), + sa.column("supports_v3t", sa.Boolean()), + ) + connection = op.get_bind() + rows = connection.execute( + sa.select(identity.c.id, identity.c.declared_metadata) + ).mappings().all() + for row in rows: + connection.execute( + identity.update() + .where(identity.c.id == row["id"]) + .values(**_legacy_values(row["declared_metadata"])) + ) + + +def upgrade() -> None: + """新增声明快照、离线回填并移除会随 package 扩张的固定列。""" + columns = _column_names() + if not columns: + return + legacy_columns = _LEGACY_COLUMNS & columns + if legacy_columns and legacy_columns != _LEGACY_COLUMNS: + raise RuntimeError("pluginidentity 旧声明字段不完整,拒绝继续迁移") + if "declared_metadata" not in columns: + op.add_column( + "pluginidentity", + sa.Column( + "declared_metadata", + sa.JSON(none_as_null=True), + nullable=True, + ), + ) + columns.add("declared_metadata") + if _LEGACY_COLUMNS <= columns: + _backfill_declared_metadata() + if legacy_columns: + with op.batch_alter_table("pluginidentity") as batch_op: + for column_name in sorted(legacy_columns): + batch_op.drop_column(column_name) + + +def downgrade() -> None: + """恢复旧版固定声明列,并删除版本化声明快照。""" + columns = _column_names() + if not columns or "declared_metadata" not in columns: + return + missing_legacy = _LEGACY_COLUMNS - columns + if missing_legacy: + with op.batch_alter_table("pluginidentity") as batch_op: + if "system_version" in missing_legacy: + batch_op.add_column( + sa.Column("system_version", sa.String(length=128)) + ) + if "supports_v3" in missing_legacy: + batch_op.add_column(sa.Column("supports_v3", sa.Boolean())) + if "supports_v3t" in missing_legacy: + batch_op.add_column(sa.Column("supports_v3t", sa.Boolean())) + _restore_legacy_columns() + with op.batch_alter_table("pluginidentity") as batch_op: + batch_op.drop_column("declared_metadata") diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 9b3c32bfb..e7c09aee8 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6773, - "edge_sha256": "3ba25180753ef7e15d9d08b7af25b66191306e8c0b465a27bed5613ed8d30c5d", + "edge_count": 6785, + "edge_sha256": "077e003bf195195e4e75dc65c9596e0b5a45c0e33441e3a22829f0cdb8d98e45", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -2112,6 +2112,7 @@ "app.api.endpoints.plugin -> app.application.commands", "app.api.endpoints.plugin -> app.application.configuration", "app.api.endpoints.plugin -> app.application.plugin", + "app.api.endpoints.plugin -> app.application.plugin.catalog", "app.api.endpoints.plugin -> app.application.plugin.config", "app.api.endpoints.plugin -> app.application.plugin.folders", "app.api.endpoints.plugin -> app.application.plugin.gateway", @@ -2744,9 +2745,15 @@ "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.declaration", "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.catalog -> app.application", + "app.application.plugin.catalog -> app.application.plugin", + "app.application.plugin.catalog -> app.application.plugin.identity", + "app.application.plugin.catalog -> app.schemas", + "app.application.plugin.catalog -> app.schemas.plugin", "app.application.plugin.config -> app.schemas", "app.application.plugin.config -> app.schemas.exception", "app.application.plugin.folders -> app.application", @@ -2763,6 +2770,9 @@ "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 -> app.application", + "app.application.plugin.identity -> app.application.plugin", + "app.application.plugin.identity -> app.application.plugin.declaration", "app.application.plugin.identity_migration -> app.application", "app.application.plugin.identity_migration -> app.application.plugin", "app.application.plugin.identity_migration -> app.application.plugin.identity", @@ -3666,6 +3676,7 @@ "app.db.adapters.outbox -> app.db.models.outbox", "app.db.adapters.pluginidentity -> app.application", "app.db.adapters.pluginidentity -> app.application.plugin", + "app.db.adapters.pluginidentity -> app.application.plugin.declaration", "app.db.adapters.pluginidentity -> app.application.plugin.identity", "app.db.adapters.pluginidentity -> app.db", "app.db.adapters.pluginidentity -> app.db.models", @@ -3675,6 +3686,7 @@ "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.declaration", "app.db.adapters.plugininstallation -> app.application.plugin.identity", "app.db.adapters.plugininstallation -> app.application.plugin.transaction", "app.db.adapters.plugininstallation -> app.db", @@ -6790,7 +6802,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 833, + "module_count": 834, "modules": [ "app", "app.adapters", @@ -7085,6 +7097,7 @@ "app.application.plugin.catalog", "app.application.plugin.config", "app.application.plugin.data", + "app.application.plugin.declaration", "app.application.plugin.folders", "app.application.plugin.gateway", "app.application.plugin.identity", diff --git a/tests/test_plugin_declared_metadata.py b/tests/test_plugin_declared_metadata.py new file mode 100644 index 000000000..4c845beec --- /dev/null +++ b/tests/test_plugin_declared_metadata.py @@ -0,0 +1,207 @@ +"""插件 package 声明快照和值对象展示回退测试。""" + +from datetime import datetime, timezone + +import pytest + +from app.application.plugin.catalog import apply_declared_metadata_fallback +from app.application.plugin.declaration import PluginDeclaredMetadata +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.schemas.plugin import Plugin, PluginRuntimeStatus + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) +OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins" + + +def _metadata( + package: dict[str, object], + *, + version: str = "1.0.0", + matches_payload: bool = True, +) -> PluginDeclaredMetadata: + """构造一份可用于身份测试的 package 声明快照。""" + return PluginDeclaredMetadata.from_package( + package, + declaration_version=version, + manifest_matches_payload=matches_payload, + ) + + +def _identity( + metadata: PluginDeclaredMetadata, + *, + version: str = "1.0.0", + plugin_id: str = "DemoPlugin", +) -> PluginIdentity: + """构造带已提交声明快照的官方在线身份。""" + return PluginIdentity( + plugin_id=plugin_id, + normalized_plugin_id=plugin_id.lower(), + 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=version, + package_generation="v3", + declared_metadata=metadata, + payload_receipt="sha256:" + "0" * 64, + revision=1, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + + +def test_declared_metadata_round_trips_package_and_storage_without_extra_fields() -> None: + """package 声明保存后应可稳定往返,并丢弃未纳入合同的字段。""" + package = { + "name": "Demo", + "description": "A demo plugin", + "icon": "demo.svg", + "author": "MoviePilot", + "system_version": ">=3.0.0", + "labels": "alpha, beta", + "level": 2, + "release": True, + "v4": True, + "v4t": False, + "unknown_field": "ignored", + "repo_url": "https://github.com/attacker/repo", + "source": "github:attacker/repo", + "token": "secret", + "plugin_public_key": "public-key", + } + + snapshot = _metadata(package, version="2.0.0") + restored = PluginDeclaredMetadata.from_storage(snapshot.to_json()) + stored = restored.to_json() + + assert restored == snapshot + assert restored.declaration_version == "2.0.0" + assert restored.manifest_matches_payload is True + assert restored.runtime_support("v4") is True + assert restored.runtime_support("v4t") is False + assert stored["manifest"] == { + "author": "MoviePilot", + "description": "A demo plugin", + "icon": "demo.svg", + "labels": ["alpha", "beta"], + "level": 2, + "name": "Demo", + "release": True, + "system_version": ">=3.0.0", + } + assert stored["runtime"] == {"v4": True, "v4t": False} + assert "unknown_field" not in str(stored) + assert "attacker" not in str(stored) + assert "secret" not in str(stored) + assert "public-key" not in str(stored) + + +def test_declared_metadata_preserves_system_version_storage_limit() -> None: + """系统版本声明继续遵守旧数据库列的长度合同,保证迁移可降级。""" + with pytest.raises(ValueError, match="长度不能超过 128"): + _metadata({"system_version": "v" * 129}) + + +def test_declared_metadata_ignores_unknown_runtime_shapes_and_is_deep_copied() -> None: + """未知代际和外部可变对象不能污染已提交快照。""" + labels = ["alpha"] + package = { + "name": "Demo", + "labels": labels, + "v4": True, + "v4t": False, + "v5": "not-a-bool", + "vX": True, + } + snapshot = _metadata(package) + labels.append("mutated-after-build") + exported = snapshot.to_json() + exported["manifest"]["labels"].append("mutated-export") + + assert snapshot.to_json()["manifest"]["labels"] == ["alpha"] + assert snapshot.runtime_support("v4") is True + assert snapshot.runtime_support("v4t") is False + assert snapshot.runtime_support("v5") is None + + +def test_declared_metadata_records_current_and_historical_release_truth() -> None: + """当前 package 与历史 Release 的声明对应关系必须可区分。""" + current = _metadata({"name": "Demo", "v3": True}, version="2.0.0") + historical = _metadata( + {"name": "Demo", "v3": True}, + version="1.0.0", + matches_payload=False, + ) + + assert current.manifest_matches_payload is True + assert historical.manifest_matches_payload is False + assert historical.declaration_version == "1.0.0" + + +def test_declared_metadata_fallback_is_batch_safe_and_preserves_runtime_fields() -> None: + """批量展示回退只补空展示字段,不覆盖运行态或另一插件身份。""" + first = _identity( + _metadata( + { + "name": "Demo from snapshot", + "description": "Saved description", + "icon": "saved.svg", + "author": "Saved author", + "labels": ["saved", "plugin"], + }, + version="2.0.0", + ), + version="2.0.0", + plugin_id="DemoPlugin", + ) + second = _identity( + _metadata({"name": "Other from snapshot"}, version="3.0.0"), + version="3.0.0", + plugin_id="OtherPlugin", + ) + plugins = [ + Plugin( + id="DemoPlugin", + plugin_name="DemoPlugin", + plugin_version=None, + runtime_status=PluginRuntimeStatus.LOAD_FAILED, + state=True, + plugin_desc=None, + ), + Plugin( + id="OtherPlugin", + plugin_name="Runtime name", + plugin_desc="Runtime description", + plugin_version="loaded-version", + runtime_status=PluginRuntimeStatus.READY, + ), + Plugin(id="MissingPlugin", plugin_name="MissingPlugin"), + ] + + result = apply_declared_metadata_fallback( + plugins, + {"demoplugin": first, "otherplugin": second}, + ) + + assert result[0].plugin_name == "Demo from snapshot" + assert result[0].plugin_desc == "Saved description" + assert result[0].plugin_icon == "saved.svg" + assert result[0].plugin_author == "Saved author" + assert result[0].plugin_version == "2.0.0" + assert result[0].plugin_label == "saved,plugin" + assert result[0].runtime_status is PluginRuntimeStatus.LOAD_FAILED + assert result[0].state is True + assert result[1].plugin_name == "Runtime name" + assert result[1].plugin_desc == "Runtime description" + assert result[1].plugin_version == "loaded-version" + assert result[1].runtime_status is PluginRuntimeStatus.READY + assert result[2].plugin_name == "MissingPlugin" diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index c0b91f162..2aafde1e1 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -1,18 +1,29 @@ import asyncio from contextlib import nullcontext -from unittest.mock import ANY, AsyncMock, MagicMock, patch +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch -from app.api.endpoints import plugin as plugin_endpoint from app import schemas -from app.api.endpoints.plugin import plugin_history -from app.api.endpoints.plugin import plugin_releases -from app.api.endpoints.plugin import reset_plugin -from app.api.endpoints.plugin import reload_plugin -from app.api.endpoints.plugin import runtime_status -from app.api.endpoints.plugin import plugin_static_file -from app.api.endpoints.plugin import uninstall_plugin +from app.api.endpoints import plugin as plugin_endpoint +from app.api.endpoints.plugin import ( + plugin_history, + plugin_releases, + plugin_static_file, + reload_plugin, + reset_plugin, + runtime_status, + uninstall_plugin, +) from app.api.endpoints.system import sync_plugin_market_from_wiki from app.application.plugin.config import PluginConfigCommand +from app.application.plugin.declaration import PluginDeclaredMetadata +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.foundation.singleton import Singleton from app.runtime.config import settings from app.runtime.extensions.plugin.admission import PluginMutationAdmission from app.runtime.extensions.plugin_manager import PluginManager @@ -20,7 +31,47 @@ from app.runtime.tasks import TaskRegistry from app.schemas.event import PluginDataResetEventData from app.schemas.plugin import PluginInstance, PluginRuntimeStatus from app.schemas.types import ChainEventType, SystemConfigKey -from app.foundation.singleton import Singleton + +NOW = datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc) +SOURCE_KEY = "github:demo/plugins" +SOURCE_URL = "https://github.com/demo/plugins" + + +def _plugin_identity( + *, + metadata: PluginDeclaredMetadata | None = None, +) -> PluginIdentity: + """构造绑定到测试仓库的插件身份。""" + has_payload = metadata is not None + return PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.THIRD_PARTY, + trusted_source_key=SOURCE_KEY, + binding_basis=PluginBindingBasis.EXPLICIT_INSTALL, + payload_source_type=( + PluginPayloadSourceType.THIRD_PARTY + if has_payload + else PluginPayloadSourceType.UNKNOWN + ), + payload_source_key=SOURCE_KEY if has_payload else None, + declared_version="1.0.0" if has_payload else None, + package_generation="v3" if has_payload else None, + declared_metadata=metadata, + payload_receipt="sha256:" + "0" * 64 if has_payload else None, + revision=1, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW if has_payload else None, + ) + + +def _persistence(identity: PluginIdentity) -> MagicMock: + """构造只暴露身份读取合同的异步持久化替身。""" + persistence = MagicMock() + persistence.get_identity = AsyncMock(return_value=identity) + return persistence def test_plugin_history_merges_remote_metadata(): @@ -36,24 +87,31 @@ def test_plugin_history_merges_remote_metadata(): ) market_plugin = schemas.Plugin( id="DemoPlugin", - repo_url="https://github.com/demo/plugins", + repo_url=SOURCE_URL, history={"v1.1.0": "- 新增更新说明"}, system_version=">=2.0.0", system_version_compatible=True, has_update=True, ) plugin_manager = MagicMock() - plugin_manager.get_local_plugins.return_value = [installed_plugin] + plugin_manager.get_installed_plugins.return_value = [installed_plugin] plugin_manager.get_local_repo_plugins.return_value = [] - plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) + persistence = _persistence(_plugin_identity()) - with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager): + with ( + patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), + patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) assert result.repo_url == "https://github.com/demo/plugins" assert result.history == {"v1.1.0": "- 新增更新说明"} assert result.system_version == ">=2.0.0" assert result.has_update + plugin_manager.async_get_plugins_from_market.assert_awaited_once_with( + SOURCE_URL, settings.VERSION_FLAG, True + ) def test_runtime_status_reports_pending_and_terminal_counts(): @@ -103,49 +161,94 @@ def test_plugin_history_returns_installed_plugin_when_remote_missing(): installed=True, ) plugin_manager = MagicMock() - plugin_manager.get_local_plugins.return_value = [installed_plugin] + plugin_manager.get_installed_plugins.return_value = [installed_plugin] plugin_manager.get_local_repo_plugins.return_value = [] - plugin_manager.async_get_online_plugins = AsyncMock(return_value=[]) + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[]) + persistence = _persistence(_plugin_identity()) - with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager): + with ( + patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), + patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) assert result.id == "DemoPlugin" assert result.history == {} -def test_plugin_history_uses_installed_repo_without_refreshing_all_markets(): +def test_plugin_history_uses_bound_repo_without_refreshing_all_markets(): """ - 已安装插件记录了来源仓库时,更新说明只刷新该仓库,避免弹窗触发全市场慢刷新。 + 更新说明只读取持久化绑定仓库,不信任运行态 DTO 中可漂移的来源地址。 """ installed_plugin = schemas.Plugin( id="DemoPlugin", plugin_name="Demo Plugin", plugin_version="1.0.0", - repo_url="https://github.com/demo/plugins", + repo_url="https://github.com/attacker/plugins", installed=True, ) market_plugin = schemas.Plugin( id="DemoPlugin", - repo_url="https://github.com/demo/plugins", + repo_url=SOURCE_URL, history={"v1.1.0": "- 新增更新说明"}, ) plugin_manager = MagicMock() - plugin_manager.get_local_plugins.return_value = [installed_plugin] + plugin_manager.get_installed_plugins.return_value = [installed_plugin] plugin_manager.get_local_repo_plugins.return_value = [] plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) plugin_manager.async_get_online_plugins = AsyncMock(return_value=[]) + persistence = _persistence(_plugin_identity()) - with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager): + with ( + patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), + patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) assert result.history == {"v1.1.0": "- 新增更新说明"} plugin_manager.async_get_plugins_from_market.assert_awaited_once_with( - "https://github.com/demo/plugins", settings.VERSION_FLAG, True + SOURCE_URL, settings.VERSION_FLAG, True ) plugin_manager.async_get_online_plugins.assert_not_awaited() +def test_plugin_history_uses_declared_metadata_when_bound_market_is_unavailable(): + """加载失败且绑定市场不可用时,详情仍返回已提交载荷的展示信息。""" + installed_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_name="DemoPlugin", + installed=True, + runtime_status=PluginRuntimeStatus.LOAD_FAILED, + ) + metadata = PluginDeclaredMetadata.from_package( + { + "name": "Saved Demo", + "description": "Saved description", + "author": "Saved author", + "v3": True, + }, + declaration_version="1.0.0", + manifest_matches_payload=True, + ) + plugin_manager = MagicMock() + plugin_manager.get_installed_plugins.return_value = [installed_plugin] + plugin_manager.get_local_repo_plugins.return_value = [] + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[]) + persistence = _persistence(_plugin_identity(metadata=metadata)) + + with ( + patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), + patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + ): + result = asyncio.run(plugin_history("DemoPlugin", None, True)) + + assert result.plugin_name == "Saved Demo" + assert result.plugin_desc == "Saved description" + assert result.plugin_author == "Saved author" + assert result.plugin_version == "1.0.0" + assert result.runtime_status is PluginRuntimeStatus.LOAD_FAILED + + def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch): """ release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。 diff --git a/tests/test_plugin_identity.py b/tests/test_plugin_identity.py index 4f2fd73c3..dde043bfc 100644 --- a/tests/test_plugin_identity.py +++ b/tests/test_plugin_identity.py @@ -9,6 +9,7 @@ import pytest import sqlalchemy as sa from sqlalchemy.orm import sessionmaker +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -34,6 +35,7 @@ def _identity( declared_version: str | None = None, ) -> PluginIdentity: """构造一份已从官方仓成功安装的物理插件身份。""" + installed_version = declared_version or "1.0.0" return PluginIdentity( plugin_id=plugin_id, normalized_plugin_id=plugin_id.lower(), @@ -42,11 +44,19 @@ def _identity( binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, payload_source_type=PluginPayloadSourceType.OFFICIAL, payload_source_key=OFFICIAL_SOURCE, - declared_version=declared_version or "1.0.0", + declared_version=installed_version, package_generation="v3", - system_version=None, - supports_v3=None, - supports_v3t=None, + declared_metadata=PluginDeclaredMetadata.from_package( + { + "name": "Demo", + "description": "Demo plugin", + "v3": True, + "v3t": False, + "release": True, + }, + declaration_version=installed_version, + manifest_matches_payload=True, + ), payload_receipt="sha256:" + "0" * 64, revision=1, created_at=NOW, @@ -197,9 +207,15 @@ def test_local_payload_preserves_trusted_online_binding() -> None: payload_source_key=None, declared_version="2.0.0-dev", package_generation="v3", - system_version=">=3.0.0", - supports_v3=True, - supports_v3t=False, + declared_metadata=PluginDeclaredMetadata.from_package( + { + "name": "Demo local", + "v3": True, + "v3t": False, + }, + declaration_version="2.0.0-dev", + manifest_matches_payload=True, + ), payload_receipt="sha256:" + "a" * 64, payload_applied_at=NOW + timedelta(seconds=1), updated_at=NOW + timedelta(seconds=1), @@ -321,7 +337,6 @@ def test_plugin_identity_rejects_noncanonical_physical_id(plugin_id) -> None: ("field_name", "value", "message"), ( ("declared_version", "v" * 65, "插件声明版本"), - ("system_version", ">" * 129, "插件系统版本要求"), ), ) def test_plugin_identity_rejects_values_longer_than_database_columns( diff --git a/tests/test_plugin_identity_migration.py b/tests/test_plugin_identity_migration.py index d01b944ac..dcbdc9ebc 100644 --- a/tests/test_plugin_identity_migration.py +++ b/tests/test_plugin_identity_migration.py @@ -22,12 +22,18 @@ except ModuleNotFoundError: from app.db.models.pluginidentity import PluginIdentity -MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9" +BASE_MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9" +INSTALLATION_MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10" +DECLARATION_MIGRATION = "database.versions.5f2a9c1e7b4d_3_0_12" -def _bind_migration(monkeypatch, connection): +def _bind_migration( + monkeypatch, + connection, + module_name: str = BASE_MIGRATION, +): """把迁移绑定到隔离数据库连接。""" - migration = importlib.import_module(MIGRATION) + migration = importlib.import_module(module_name) context = MigrationContext.configure(connection) monkeypatch.setattr(migration, "op", Operations(context)) return migration @@ -36,13 +42,27 @@ def _bind_migration(monkeypatch, connection): def test_plugin_identity_migration_upgrades_twice_and_downgrades( monkeypatch, ) -> None: - """旧 SQLite schema 应可重复升级并完整删除 dormant 身份表。""" + """SQLite 应可从旧身份表升级到声明快照并按逆序完整回滚。""" engine = sa.create_engine("sqlite://") with engine.begin() as connection: migration = _bind_migration(monkeypatch, connection) migration.upgrade() migration.upgrade() + installation = _bind_migration( + monkeypatch, + connection, + INSTALLATION_MIGRATION, + ) + installation.upgrade() + installation.upgrade() + declaration = _bind_migration( + monkeypatch, + connection, + DECLARATION_MIGRATION, + ) + declaration.upgrade() + declaration.upgrade() inspector = sa.inspect(connection) assert "pluginidentity" in inspector.get_table_names() @@ -65,9 +85,23 @@ def test_plugin_identity_migration_upgrades_twice_and_downgrades( "ck_pluginidentity_normalized_plugin_id", "ck_pluginidentity_revision", } + assert "plugininstallation" in inspector.get_table_names() + declaration.downgrade() + installation.downgrade() migration.downgrade() - assert "pluginidentity" not in sa.inspect(connection).get_table_names() + assert not { + "pluginidentity", + "plugininstallation", + } & set(sa.inspect(connection).get_table_names()) + + migration.upgrade() + installation.upgrade() + declaration.upgrade() + assert { + column["name"] + for column in sa.inspect(connection).get_columns("pluginidentity") + } == {column.name for column in PluginIdentity.__table__.columns} def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) -> None: @@ -75,7 +109,11 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) -> engine = sa.create_engine("sqlite://") with engine.begin() as connection: PluginIdentity.__table__.create(connection) - migration = _bind_migration(monkeypatch, connection) + migration = _bind_migration( + monkeypatch, + connection, + DECLARATION_MIGRATION, + ) migration.upgrade() migration.upgrade() @@ -86,9 +124,126 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) -> } == {column.name for column in PluginIdentity.__table__.columns} +def test_plugin_identity_declaration_migration_backfills_and_restores_legacy_fields( + monkeypatch, +) -> None: + """声明迁移应保守回填旧字段,并能在降级时恢复旧投影。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + base = _bind_migration(monkeypatch, connection) + base.upgrade() + table = sa.Table( + "pluginidentity", + sa.MetaData(), + autoload_with=connection, + ) + connection.execute( + table.insert().values( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type="official", + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis="official_default", + payload_source_type="official", + payload_source_key="github:jxxghp/moviepilot-plugins", + declared_version="1.0.0", + package_generation="v3", + system_version=">=3.0.0", + supports_v3=True, + supports_v3t=False, + payload_receipt="sha256:" + "0" * 64, + revision=1, + created_at="2026-08-25T12:00:00+00:00", + updated_at="2026-08-25T12:00:00+00:00", + bound_at="2026-08-25T12:00:00+00:00", + payload_applied_at="2026-08-25T12:00:00+00:00", + ) + ) + unknown_id = connection.execute( + table.insert().values( + plugin_id="UnknownPlugin", + normalized_plugin_id="unknownplugin", + trusted_source_type="unknown", + binding_basis="legacy_unbound", + payload_source_type="unknown", + revision=1, + created_at="2026-08-25T12:00:00+00:00", + updated_at="2026-08-25T12:00:00+00:00", + ).returning(table.c.id) + ).scalar_one() + + declaration = _bind_migration( + monkeypatch, + connection, + DECLARATION_MIGRATION, + ) + declaration.upgrade() + current = sa.Table( + "pluginidentity", + sa.MetaData(), + autoload_with=connection, + ) + row = connection.execute( + sa.select(current).where(current.c.plugin_id == "DemoPlugin") + ).mappings().one() + assert row["declared_metadata"] == { + "schema_version": 1, + "declaration_version": None, + "manifest_matches_payload": False, + "manifest": {"system_version": ">=3.0.0"}, + "runtime": {"v3": True, "v3t": False}, + } + assert connection.execute( + sa.select(current.c.id).where(current.c.declared_metadata.is_(None)) + ).scalars().all() == [unknown_id] + + declaration.downgrade() + restored = sa.Table( + "pluginidentity", + sa.MetaData(), + autoload_with=connection, + ) + restored_row = connection.execute( + sa.select(restored).where(restored.c.plugin_id == "DemoPlugin") + ).mappings().one() + assert restored_row["system_version"] == ">=3.0.0" + assert restored_row["supports_v3"] is True + assert restored_row["supports_v3t"] is False + assert "declared_metadata" not in restored.c + + +def test_plugin_identity_declaration_migration_rejects_partial_legacy_schema( + monkeypatch, +) -> None: + """非 canonical 旧字段集合必须停止迁移,不能静默丢弃残余声明。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + base = _bind_migration(monkeypatch, connection) + base.upgrade() + with Operations(MigrationContext.configure(connection)).batch_alter_table( + "pluginidentity" + ) as batch_op: + batch_op.drop_column("supports_v3t") + + declaration = _bind_migration( + monkeypatch, + connection, + DECLARATION_MIGRATION, + ) + with pytest.raises(RuntimeError, match="旧声明字段不完整"): + declaration.upgrade() + + columns = { + column["name"] + for column in sa.inspect(connection).get_columns("pluginidentity") + } + assert "declared_metadata" not in columns + assert {"system_version", "supports_v3"} <= columns + + def test_plugin_identity_migration_matches_postgresql_identity() -> None: """独立 Alembic 路径应保留 PostgreSQL 循环 Identity 主键。""" - migration = importlib.import_module(MIGRATION) + migration = importlib.import_module(BASE_MIGRATION) metadata = sa.MetaData() table = sa.Table( "pluginidentity", @@ -148,25 +303,80 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None: migration = _bind_migration(monkeypatch, connection) migration.upgrade() migration.upgrade() - - table = sa.Table( + installation = _bind_migration( + monkeypatch, + connection, + INSTALLATION_MIGRATION, + ) + installation.upgrade() + legacy_table = sa.Table( "pluginidentity", sa.MetaData(), autoload_with=connection, ) inserted_id = connection.execute( - table.insert().values( + legacy_table.insert().values( plugin_id="DemoPlugin", normalized_plugin_id="demoplugin", + trusted_source_type="official", + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis="official_default", + payload_source_type="official", + payload_source_key="github:jxxghp/moviepilot-plugins", + declared_version="1.0.0", + package_generation="v3", + system_version=">=3.0.0", + supports_v3=True, + supports_v3t=False, + payload_receipt="sha256:" + "0" * 64, + revision=1, + created_at="2026-08-25T12:00:00+00:00", + updated_at="2026-08-25T12:00:00+00:00", + bound_at="2026-08-25T12:00:00+00:00", + payload_applied_at="2026-08-25T12:00:00+00:00", + ).returning(legacy_table.c.id) + ).scalar_one() + assert inserted_id == 1 + unknown_id = connection.execute( + legacy_table.insert().values( + plugin_id="UnknownPlugin", + normalized_plugin_id="unknownplugin", trusted_source_type="unknown", binding_basis="legacy_unbound", payload_source_type="unknown", revision=1, created_at="2026-08-25T12:00:00+00:00", updated_at="2026-08-25T12:00:00+00:00", - ).returning(table.c.id) + ).returning(legacy_table.c.id) ).scalar_one() - assert inserted_id == 1 + assert unknown_id == 2 + declaration = _bind_migration( + monkeypatch, + connection, + DECLARATION_MIGRATION, + ) + declaration.upgrade() + declaration.upgrade() + + table = sa.Table( + "pluginidentity", + sa.MetaData(), + autoload_with=connection, + ) + row = connection.execute( + sa.select(table).where(table.c.id == inserted_id) + ).mappings().one() + assert row["declared_metadata"] == { + "schema_version": 1, + "declaration_version": None, + "manifest_matches_payload": False, + "manifest": {"system_version": ">=3.0.0"}, + "runtime": {"v3": True, "v3t": False}, + } + unknown_row = connection.execute( + sa.select(table).where(table.c.id == unknown_id) + ).mappings().one() + assert unknown_row["declared_metadata"] is None with pytest.raises(sa.exc.IntegrityError): with connection.begin_nested(): @@ -183,6 +393,35 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None: ) ) + declaration.downgrade() + restored = sa.Table( + "pluginidentity", + sa.MetaData(), + autoload_with=connection, + ) + restored_row = connection.execute( + sa.select(restored).where(restored.c.id == inserted_id) + ).mappings().one() + assert restored_row["system_version"] == ">=3.0.0" + assert restored_row["supports_v3"] is True + assert restored_row["supports_v3t"] is False + + declaration.upgrade() + reupgraded = sa.Table( + "pluginidentity", + sa.MetaData(), + autoload_with=connection, + ) + reupgraded_row = connection.execute( + sa.select(reupgraded).where(reupgraded.c.id == inserted_id) + ).mappings().one() + assert reupgraded_row["declared_metadata"]["runtime"] == { + "v3": True, + "v3t": False, + } + + declaration.downgrade() + installation.downgrade() migration.downgrade() assert "pluginidentity" not in sa.inspect(connection).get_table_names() finally: diff --git a/tests/test_plugin_identity_startup_migration.py b/tests/test_plugin_identity_startup_migration.py index da405d36c..7fbd72f12 100644 --- a/tests/test_plugin_identity_startup_migration.py +++ b/tests/test_plugin_identity_startup_migration.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -34,6 +35,15 @@ THIRD_PARTY_REPO = "https://github.com/example/MoviePilot-Plugins" THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins" +def _metadata(version: str, *, matches_payload: bool = True) -> PluginDeclaredMetadata: + """构造测试用 package 声明快照。""" + return PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True, "v3t": False}, + declaration_version=version, + manifest_matches_payload=matches_payload, + ) + + class _Persistence: """提供可观察 CAS 竞争的内存迁移持久化端口。""" @@ -140,9 +150,7 @@ def _legacy(plugin_id: str = "DemoPlugin") -> PluginIdentity: payload_source_key=None, declared_version=None, package_generation=None, - system_version=None, - supports_v3=None, - supports_v3t=None, + declared_metadata=None, payload_receipt=None, revision=1, created_at=NOW, @@ -356,6 +364,7 @@ async def test_migration_does_not_replace_existing_bound_or_local_identity() -> payload_source_type=PluginPayloadSourceType.LOCAL, declared_version="1.0.0-dev", package_generation="v3", + declared_metadata=_metadata("1.0.0-dev"), payload_receipt="sha256:" + "1" * 64, payload_applied_at=NOW, ) @@ -399,6 +408,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload() payload_source_type=PluginPayloadSourceType.LOCAL, declared_version="9.9.10", package_generation="v3", + declared_metadata=_metadata("9.9.10"), payload_receipt="sha256:" + "2" * 64, bound_at=NOW, payload_applied_at=NOW, @@ -409,6 +419,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload() payload_source_type=PluginPayloadSourceType.LOCAL, declared_version="1.0.0-dev", package_generation="v3", + declared_metadata=_metadata("1.0.0-dev"), payload_receipt="sha256:" + "3" * 64, payload_applied_at=NOW, ) @@ -421,6 +432,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload() payload_source_key=OFFICIAL_SOURCE, declared_version="1.2.0", package_generation="v3", + declared_metadata=_metadata("1.2.0"), payload_receipt="sha256:" + "4" * 64, bound_at=NOW, payload_applied_at=NOW, @@ -470,6 +482,7 @@ async def test_sync_runs_identity_migration_before_automatic_install( payload_source_type=PluginPayloadSourceType.LOCAL, declared_version="9.9.10", package_generation="v3", + declared_metadata=_metadata("9.9.10"), payload_receipt="sha256:" + "5" * 64, bound_at=NOW, payload_applied_at=NOW, diff --git a/tests/test_plugin_identity_transitions.py b/tests/test_plugin_identity_transitions.py index 9770dcff1..76721f622 100644 --- a/tests/test_plugin_identity_transitions.py +++ b/tests/test_plugin_identity_transitions.py @@ -7,6 +7,7 @@ import pytest import sqlalchemy as sa from sqlalchemy.orm import sessionmaker +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -23,6 +24,15 @@ OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins" THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins" +def _metadata(version: str, *, matches_payload: bool = True) -> PluginDeclaredMetadata: + """构造测试用 package 声明快照。""" + return PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True, "v3t": False}, + declaration_version=version, + manifest_matches_payload=matches_payload, + ) + + def _identity( plugin_id: str = "DemoPlugin", *, @@ -43,9 +53,13 @@ def _identity( payload_source_key=payload_source_key, declared_version="1.0.0", package_generation="v3", - system_version=None, - supports_v3=None, - supports_v3t=None, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True, "v3t": False}, + declaration_version="1.0.0", + manifest_matches_payload=True, + ) + if payload_source_type is not PluginPayloadSourceType.UNKNOWN + else None, payload_receipt="sha256:" + "0" * 64, revision=1, created_at=NOW, @@ -95,9 +109,7 @@ def _legacy_identity(plugin_id: str = "DemoPlugin") -> PluginIdentity: payload_source_key=None, declared_version=None, package_generation=None, - system_version=None, - supports_v3=None, - supports_v3t=None, + declared_metadata=None, payload_receipt=None, revision=1, created_at=NOW, @@ -124,6 +136,7 @@ def _online_binding_target( payload_source_key=source_key, declared_version="2.0.0", package_generation="v3", + declared_metadata=_metadata("2.0.0"), payload_receipt="sha256:" + "2" * 64, updated_at=updated_at, bound_at=updated_at, @@ -214,6 +227,7 @@ def test_bind_local_commits_only_legacy_unbound_transition(identity_store) -> No payload_source_type=PluginPayloadSourceType.LOCAL, declared_version="2.0.0-dev", package_generation="v3", + declared_metadata=_metadata("2.0.0-dev"), payload_receipt="sha256:" + "1" * 64, updated_at=NOW + timedelta(seconds=1), payload_applied_at=NOW + timedelta(seconds=1), @@ -249,6 +263,7 @@ def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) -> payload_source_type=PluginPayloadSourceType.LOCAL, declared_version="2.0.0-dev", package_generation="v3", + declared_metadata=_metadata("2.0.0-dev"), payload_receipt="sha256:" + "1" * 64, updated_at=NOW + timedelta(seconds=1), payload_applied_at=NOW + timedelta(seconds=1), @@ -302,6 +317,7 @@ def test_first_local_install_still_uses_ordinary_create(identity_store) -> None: payload_source_type=PluginPayloadSourceType.LOCAL, payload_source_key=None, declared_version="2.0.0-dev", + declared_metadata=_metadata("2.0.0-dev"), payload_receipt="sha256:" + "1" * 64, bound_at=None, ) @@ -325,6 +341,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) - payload_source_key=None, bound_at=None, declared_version="2.0.0-dev", + declared_metadata=_metadata("2.0.0-dev"), payload_receipt="sha256:" + "1" * 64, updated_at=NOW + timedelta(seconds=1), payload_applied_at=NOW + timedelta(seconds=1), @@ -341,6 +358,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) - payload_source_key=None, declared_version=None, package_generation=None, + declared_metadata=None, payload_receipt=None, bound_at=None, payload_applied_at=None, @@ -352,6 +370,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) - payload_source_type=PluginPayloadSourceType.LOCAL, declared_version="2.0.0-dev", package_generation="v3", + declared_metadata=_metadata("2.0.0-dev"), payload_receipt="sha256:" + "1" * 64, updated_at=NOW + timedelta(seconds=1), payload_applied_at=NOW + timedelta(seconds=1), diff --git a/tests/test_plugin_install_admission.py b/tests/test_plugin_install_admission.py index 236809c82..989ea822c 100644 --- a/tests/test_plugin_install_admission.py +++ b/tests/test_plugin_install_admission.py @@ -10,6 +10,7 @@ from app.application.plugin.admission import ( PluginSourceAdmissionError, admit_plugin_install, ) +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -58,9 +59,11 @@ def _identity() -> PluginIdentity: payload_source_key=OFFICIAL, declared_version="1.0.0", package_generation="v3", - system_version=None, - supports_v3=True, - supports_v3t=None, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True, "v3t": False}, + declaration_version="1.0.0", + manifest_matches_payload=True, + ), payload_receipt="sha256:" + "0" * 64, revision=3, created_at=NOW, @@ -104,6 +107,55 @@ def test_same_source_update_preserves_binding_and_advances_payload() -> None: assert target.declared_version == "2.0.0" +def test_current_release_marks_declaration_as_matching_payload() -> None: + """按当前 package 版本安装时,声明快照应标记为对应当前载荷。""" + 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", + ), + identity=None, + now=NOW, + ) + + target = admission.build_identity( + payload_receipt="sha256:" + "7" * 64, + applied_at=NOW, + ) + + assert target.declared_metadata is not None + assert target.declared_metadata.declaration_version == "2.0.0" + assert target.declared_metadata.manifest_matches_payload is True + + +def test_historical_release_marks_declaration_as_not_matching_payload() -> None: + """安装历史 Release 时必须保留其声明版本,并标记与当前载荷不对应。""" + 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", + ), + identity=None, + now=NOW, + ) + + target = admission.build_identity( + payload_receipt="sha256:" + "8" * 64, + applied_at=NOW, + declared_version="1.0.0", + manifest_matches_payload=False, + ) + + assert target.declared_version == "1.0.0" + assert target.declared_metadata is not None + assert target.declared_metadata.declaration_version == "2.0.0" + assert target.declared_metadata.manifest_matches_payload is False + + def test_first_online_binding_uses_payload_commit_time() -> None: """首次在线绑定在载荷提交时生效,不能早于身份创建时间。""" applied_at = NOW + timedelta(seconds=1) @@ -315,7 +367,7 @@ def test_legacy_identity_can_bind_explicit_online_source() -> None: payload_source_key=None, declared_version=None, package_generation=None, - supports_v3=None, + declared_metadata=None, payload_receipt=None, bound_at=None, payload_applied_at=None, diff --git a/tests/test_plugin_install_command.py b/tests/test_plugin_install_command.py index 8168ce630..62f62c970 100644 --- a/tests/test_plugin_install_command.py +++ b/tests/test_plugin_install_command.py @@ -14,6 +14,7 @@ from app.application.plugin.admission import ( PluginInstallAdmissionRequest, admit_plugin_install, ) +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -56,9 +57,11 @@ def _identity(*, version: str = "1.0.0", revision: int = 1) -> PluginIdentity: payload_source_key=SOURCE_KEY, declared_version=version, package_generation="v3", - system_version=None, - supports_v3=True, - supports_v3t=False, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True, "v3t": False}, + declaration_version=version, + manifest_matches_payload=True, + ), payload_receipt=RECEIPT, revision=revision, created_at=NOW, @@ -197,6 +200,8 @@ class _PersistenceSpy: self.calls.append("journal_commit") if self.commit_error: raise self.commit_error + if identity_target is not None: + self.identity = identity_target record = self.records[transaction_id] record = replace( record, @@ -624,6 +629,33 @@ async def test_precommit_failure_restores_files_and_runtime( rollback_reloader.assert_not_awaited() +@pytest.mark.asyncio +async def test_precommit_failure_keeps_previous_declared_metadata_snapshot(): + """提交前失败不得把新声明快照写成当前身份事实。""" + previous = _identity(version="1.0.0", revision=3) + persistence = _PersistenceSpy([], identity=previous) + rollback_reloader = AsyncMock() + target_reloader = AsyncMock(side_effect=RuntimeError("reload failed")) + command, _, _ = _command( + persistence=persistence, + target_reloader=target_reloader, + rollback_reloader=rollback_reloader, + ) + + result = await _execute( + command, + admission=_admission(identity=previous), + ) + + assert result.success is False + assert result.failure_stage == "runtime_reload" + assert result.rollback.journal_deleted is True + assert persistence.identity is previous + assert persistence.identity.declared_version == "1.0.0" + assert persistence.identity.declared_metadata is previous.declared_metadata + rollback_reloader.assert_awaited_once_with("DemoPlugin") + + @pytest.mark.asyncio async def test_database_commit_failure_restores_runtime_before_deleting_journal(): """数据库最终提交失败时,文件、运行态和 PREPARED journal 必须一起补偿。""" diff --git a/tests/test_plugin_install_gateway.py b/tests/test_plugin_install_gateway.py index b2b25548e..6e5429a56 100644 --- a/tests/test_plugin_install_gateway.py +++ b/tests/test_plugin_install_gateway.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock import pytest +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.gateway import PluginInstallGateway from app.application.plugin.identity import ( PluginBindingBasis, @@ -141,9 +142,11 @@ async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None payload_source_key=None, declared_version="9.9.9", package_generation="v3", - system_version=None, - supports_v3=True, - supports_v3t=None, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo local", "v3": True}, + declaration_version="9.9.9", + manifest_matches_payload=True, + ), payload_receipt="sha256:" + "1" * 64, revision=3, created_at=NOW, @@ -253,9 +256,11 @@ async def test_gateway_forwards_explicit_source_change_revision() -> None: payload_source_key="github:jxxghp/moviepilot-plugins", declared_version="1.0.0", package_generation="v3", - system_version=None, - supports_v3=True, - supports_v3t=None, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True}, + declaration_version="1.0.0", + manifest_matches_payload=True, + ), payload_receipt="sha256:" + "0" * 64, revision=4, created_at=NOW, diff --git a/tests/test_plugin_install_transaction.py b/tests/test_plugin_install_transaction.py index aab7ad22a..3ecd9e9ea 100644 --- a/tests/test_plugin_install_transaction.py +++ b/tests/test_plugin_install_transaction.py @@ -26,6 +26,7 @@ except ModuleNotFoundError: POSTGRESQL_DIALECT = "postgresql+psycopg" +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -63,9 +64,11 @@ def _identity( payload_source_key="github:jxxghp/moviepilot-plugins", declared_version=version, package_generation="v3", - system_version=None, - supports_v3=True, - supports_v3t=False, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True, "v3t": False}, + declaration_version=version, + manifest_matches_payload=True, + ), payload_receipt="sha256:" + "0" * 64, revision=revision, created_at=NOW, @@ -238,8 +241,11 @@ def _identity_model(identity: PluginIdentity) -> PluginIdentityModel: 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, + declared_metadata=( + identity.declared_metadata.to_json() + if identity.declared_metadata is not None + else None + ), payload_receipt=identity.payload_receipt, revision=identity.revision, created_at=identity.created_at.isoformat(), @@ -610,6 +616,10 @@ def postgresql_installation_stores(): connection, "database.versions.e4f7a1b2c3d5_3_0_10", ) + _upgrade_migration( + connection, + "database.versions.5f2a9c1e7b4d_3_0_12", + ) factory = sessionmaker(bind=engine, expire_on_commit=False) _set_config(factory, []) first = TransactionalPluginInstallationStore( diff --git a/tests/test_plugin_installation_recovery.py b/tests/test_plugin_installation_recovery.py index acd2ebd09..31d0652a1 100644 --- a/tests/test_plugin_installation_recovery.py +++ b/tests/test_plugin_installation_recovery.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock import pytest +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( PluginBindingBasis, PluginIdentity, @@ -38,9 +39,11 @@ def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity: payload_source_key="github:jxxghp/moviepilot-plugins", declared_version="2.0.0", package_generation="v3", - system_version=None, - supports_v3=True, - supports_v3t=True, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo", "v3": True, "v3t": True}, + declaration_version="2.0.0", + manifest_matches_payload=True, + ), payload_receipt=receipt, revision=revision, created_at=NOW, diff --git a/tests/test_plugin_source_policy.py b/tests/test_plugin_source_policy.py index c7ee9faa7..97b56da11 100644 --- a/tests/test_plugin_source_policy.py +++ b/tests/test_plugin_source_policy.py @@ -64,9 +64,7 @@ def _identity(source_type: TrustedPluginSourceType, source_key: str) -> PluginId payload_source_key=None, declared_version=None, package_generation=None, - system_version=None, - supports_v3=None, - supports_v3t=None, + declared_metadata=None, payload_receipt=None, revision=1, created_at=now, diff --git a/tests/test_plugin_sync_service.py b/tests/test_plugin_sync_service.py index df40cbb89..6e91c57ff 100644 --- a/tests/test_plugin_sync_service.py +++ b/tests/test_plugin_sync_service.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock import pytest +from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.gateway import PluginInstallGateway from app.application.plugin.identity import ( PluginBindingBasis, @@ -165,9 +166,11 @@ async def test_market_sync_reuses_startup_lease_through_real_gateway( payload_source_key=None, declared_version="9.9.10", package_generation="v3", - system_version=None, - supports_v3=True, - supports_v3t=None, + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo local", "v3": True}, + declaration_version="9.9.10", + manifest_matches_payload=True, + ), payload_receipt="sha256:" + "0" * 64, revision=1, created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),