mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
feat(plugin): persist declared metadata snapshots (#6467)
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user