""" 插件来源身份专用转换命令的 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_preserves_legacy_unknown_payload_binding(identity_store) -> None: """ 存量身份可先固化默认在线来源,且不伪造尚未应用的载荷事实。 """ legacy = identity_store.compare_and_set( _legacy_identity("LegacyInventoryPlugin"), expected_revision=None, ) target = replace( legacy, trusted_source_type=TrustedPluginSourceType.OFFICIAL, trusted_source_key=OFFICIAL_SOURCE, binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, updated_at=NOW + timedelta(seconds=1), bound_at=NOW + timedelta(seconds=1), ) bound = identity_store.bind_online( target, expected_revision=legacy.revision, ) assert bound.trusted_source_type is TrustedPluginSourceType.OFFICIAL assert bound.trusted_source_key == OFFICIAL_SOURCE assert bound.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT assert bound.payload_source_type is PluginPayloadSourceType.UNKNOWN assert bound.payload_applied_at is None assert 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