Files
MoviePilot/tests/test_plugin_install_admission.py
T

428 lines
14 KiB
Python

"""插件来源准入与目标身份规划测试。"""
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.declaration import PluginDeclaredMetadata
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",
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,
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_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)
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="状态已变化"):
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,
declared_metadata=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,
)