Files
MoviePilot/tests/test_plugin_identity_transitions.py
T

408 lines
16 KiB
Python

"""插件来源身份专用转换命令的 CAS 合同测试。"""
from dataclasses import replace
from datetime import datetime, timedelta, timezone
import pytest
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginIdentityConflictError,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore
from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel
from app.db.uow import SqlAlchemyUnitOfWork
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
def _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",
*,
trusted_source_type: TrustedPluginSourceType = TrustedPluginSourceType.OFFICIAL,
trusted_source_key: str | None = OFFICIAL_SOURCE,
binding_basis: PluginBindingBasis = PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type: PluginPayloadSourceType = PluginPayloadSourceType.OFFICIAL,
payload_source_key: str | None = OFFICIAL_SOURCE,
) -> PluginIdentity:
"""构造一份带完整在线载荷审计事实的插件身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=trusted_source_type,
trusted_source_key=trusted_source_key,
binding_basis=binding_basis,
payload_source_type=payload_source_type,
payload_source_key=payload_source_key,
declared_version="1.0.0",
package_generation="v3",
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,
updated_at=NOW,
bound_at=NOW if trusted_source_type is not TrustedPluginSourceType.UNKNOWN else None,
payload_applied_at=NOW,
)
@pytest.fixture
def identity_store(tmp_path):
"""创建可验证事务回滚和 revision CAS 的独立 SQLite 身份表。"""
engine = sa.create_engine(f"sqlite:///{tmp_path / 'plugin-identity.db'}")
PluginIdentityModel.__table__.create(engine)
factory = sessionmaker(bind=engine)
try:
yield TransactionalPluginIdentityStore(factory)
finally:
engine.dispose()
def _third_party_target(identity: PluginIdentity) -> PluginIdentity:
"""构造一次明确指向第三方在线仓库的换源目标。"""
return replace(
identity,
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
trusted_source_key=THIRD_PARTY_SOURCE,
binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE,
payload_source_type=PluginPayloadSourceType.THIRD_PARTY,
payload_source_key=THIRD_PARTY_SOURCE,
declared_version="2.0.0",
updated_at=NOW + timedelta(seconds=1),
bound_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
def _legacy_identity(plugin_id: str = "DemoPlugin") -> PluginIdentity:
"""构造尚未建立可信来源且没有已知载荷的存量身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
declared_version=None,
package_generation=None,
declared_metadata=None,
payload_receipt=None,
revision=1,
created_at=NOW,
updated_at=NOW,
bound_at=None,
payload_applied_at=None,
)
def _online_binding_target(
identity: PluginIdentity,
*,
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
source_key: str = THIRD_PARTY_SOURCE,
updated_at: datetime = NOW + timedelta(seconds=1),
) -> PluginIdentity:
"""构造用户明确选定在线仓库后的首次绑定目标。"""
return replace(
identity,
trusted_source_type=source_type,
trusted_source_key=source_key,
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
payload_source_type=PluginPayloadSourceType(source_type.value),
payload_source_key=source_key,
declared_version="2.0.0",
package_generation="v3",
declared_metadata=_metadata("2.0.0"),
payload_receipt="sha256:" + "2" * 64,
updated_at=updated_at,
bound_at=updated_at,
payload_applied_at=updated_at,
)
def test_change_source_commits_explicit_online_transition(identity_store) -> None:
"""显式换源必须保留创建时间并只推进一个 revision。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
changed = identity_store.change_source(
_third_party_target(original),
expected_revision=original.revision,
)
assert changed.trusted_source_type is TrustedPluginSourceType.THIRD_PARTY
assert changed.trusted_source_key == THIRD_PARTY_SOURCE
assert changed.payload_source_type is PluginPayloadSourceType.THIRD_PARTY
assert changed.payload_source_key == THIRD_PARTY_SOURCE
assert changed.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
assert changed.created_at == original.created_at
assert changed.revision == original.revision + 1
assert identity_store.get(original.plugin_id) == changed
def test_change_source_rejects_revision_competition(identity_store) -> None:
"""换源目标使用旧 revision 时不能覆盖已经提交的身份。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
changed = identity_store.change_source(
_third_party_target(original),
expected_revision=original.revision,
)
stale_target = replace(
_third_party_target(original),
trusted_source_key=OFFICIAL_SOURCE,
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
payload_source_key=OFFICIAL_SOURCE,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
updated_at=NOW + timedelta(seconds=2),
bound_at=NOW + timedelta(seconds=2),
payload_applied_at=NOW + timedelta(seconds=2),
)
with pytest.raises(PluginIdentityConflictError, match="revision"):
identity_store.change_source(stale_target, expected_revision=original.revision)
assert identity_store.get(original.plugin_id) == changed
def test_change_source_rejects_same_source_and_local_payload(identity_store) -> None:
"""换源必须改变实际在线来源,且不能以本地载荷冒充在线换源。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
same_source = replace(
original,
binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE,
updated_at=NOW + timedelta(seconds=1),
bound_at=NOW + timedelta(seconds=1),
payload_applied_at=NOW + timedelta(seconds=1),
)
with pytest.raises(PluginIdentityConflictError, match="来源必须变化"):
identity_store.change_source(
same_source,
expected_revision=original.revision,
)
local_payload = replace(
_third_party_target(original),
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
)
with pytest.raises(PluginIdentityConflictError, match="在线载荷"):
identity_store.change_source(
local_payload,
expected_revision=original.revision,
)
assert identity_store.get(original.plugin_id) == original
def test_bind_local_commits_only_legacy_unbound_transition(identity_store) -> None:
"""本地绑定只能把存量未绑定行转换为本地专属身份。"""
legacy = _legacy_identity()
original = identity_store.compare_and_set(legacy, expected_revision=None)
local = replace(
original,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
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),
)
changed = identity_store.bind_local(
local,
expected_revision=original.revision,
)
assert changed.trusted_source_type is TrustedPluginSourceType.UNKNOWN
assert changed.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert changed.payload_source_type is PluginPayloadSourceType.LOCAL
assert changed.created_at == original.created_at
assert changed.revision == 2
assert identity_store.get(original.plugin_id) == changed
def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) -> None:
"""显式在线安装可绑定存量未知来源,也可承接先本地开发的插件。"""
legacy = identity_store.compare_and_set(
_legacy_identity("LegacyPlugin"),
expected_revision=None,
)
legacy_bound = identity_store.bind_online(
_online_binding_target(legacy),
expected_revision=legacy.revision,
)
local = replace(
_legacy_identity("LocalPlugin"),
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
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),
)
local = identity_store.compare_and_set(local, expected_revision=None)
local_bound = identity_store.bind_online(
_online_binding_target(
local,
source_type=TrustedPluginSourceType.OFFICIAL,
source_key=OFFICIAL_SOURCE,
updated_at=NOW + timedelta(seconds=2),
),
expected_revision=local.revision,
)
assert legacy_bound.trusted_source_key == THIRD_PARTY_SOURCE
assert legacy_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
assert legacy_bound.revision == 2
assert local_bound.trusted_source_key == OFFICIAL_SOURCE
assert local_bound.payload_source_type is PluginPayloadSourceType.OFFICIAL
assert local_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
assert local_bound.revision == 2
def test_bind_online_rejects_bound_identity_and_stale_revision(identity_store) -> None:
"""首次在线绑定不能覆盖已有可信来源,也不能使用失效 revision。"""
bound = identity_store.compare_and_set(_identity(), expected_revision=None)
with pytest.raises(PluginIdentityConflictError, match="未绑定"):
identity_store.bind_online(
_third_party_target(bound),
expected_revision=bound.revision,
)
legacy = identity_store.compare_and_set(
_legacy_identity("StalePlugin"),
expected_revision=None,
)
target = _online_binding_target(legacy)
identity_store.bind_online(target, expected_revision=legacy.revision)
with pytest.raises(PluginIdentityConflictError, match="revision"):
identity_store.bind_online(target, expected_revision=legacy.revision)
def test_first_local_install_still_uses_ordinary_create(identity_store) -> None:
"""未安装插件的首次本地载荷仍可由普通 create 建立身份。"""
local = replace(
_identity(),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="2.0.0-dev",
declared_metadata=_metadata("2.0.0-dev"),
payload_receipt="sha256:" + "1" * 64,
bound_at=None,
)
created = identity_store.compare_and_set(local, expected_revision=None)
assert created.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert created.payload_source_type is PluginPayloadSourceType.LOCAL
assert created.revision == 1
def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -> None:
"""本地绑定不能绕过已绑定身份或 revision 条件。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
local = replace(
original,
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
bound_at=None,
declared_version="2.0.0-dev",
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),
)
with pytest.raises(PluginIdentityConflictError, match="legacy_unbound"):
identity_store.bind_local(local, expected_revision=original.revision)
legacy = replace(
_identity("LegacyPlugin"),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
declared_version=None,
package_generation=None,
declared_metadata=None,
payload_receipt=None,
bound_at=None,
payload_applied_at=None,
)
identity_store.compare_and_set(legacy, expected_revision=None)
changed = replace(
legacy,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
declared_version="2.0.0-dev",
package_generation="v3",
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),
)
identity_store.bind_local(changed, expected_revision=1)
with pytest.raises(PluginIdentityConflictError, match="revision"):
identity_store.bind_local(changed, expected_revision=1)
def test_ordinary_writer_still_rejects_binding_change(identity_store) -> None:
"""普通 writer 不能借 CAS 参数伪装成来源绑定转换。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
with pytest.raises(PluginIdentityConflictError, match="不能改变"):
identity_store.compare_and_set(
_third_party_target(original),
expected_revision=original.revision,
)
assert identity_store.get(original.plugin_id) == original
def test_transition_rolls_back_when_commit_fails(identity_store, monkeypatch) -> None:
"""转换提交失败时必须回滚暂存的身份变化。"""
original = identity_store.compare_and_set(_identity(), expected_revision=None)
target = _third_party_target(original)
def fail_commit(_unit_of_work: SqlAlchemyUnitOfWork) -> None:
"""模拟数据库提交失败。"""
raise RuntimeError("commit failed")
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
identity_store.change_source(target, expected_revision=original.revision)
assert identity_store.get(original.plugin_id) == original