Files
MoviePilot/tests/test_plugin_identity.py
T

450 lines
15 KiB
Python

"""插件身份事实、条件写和存量迁移决策测试。"""
import threading
from concurrent.futures import ThreadPoolExecutor
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,
PluginMarketAvailability,
PluginPayloadSourceType,
PluginSourceCandidate,
TrustedPluginSourceType,
plan_legacy_plugin_identity,
)
from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore
from app.db.models import load_all_models
from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
def _identity(
plugin_id: str = "DemoPlugin",
*,
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(),
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=installed_version,
package_generation="v3",
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,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
@pytest.fixture
def identity_store(tmp_path):
"""创建可跨线程竞争的独立 SQLite 身份表。"""
engine = sa.create_engine(
f"sqlite:///{tmp_path / 'plugin-identity.db'}",
connect_args={"check_same_thread": False},
)
PluginIdentityModel.__table__.create(engine)
factory = sessionmaker(bind=engine)
try:
yield TransactionalPluginIdentityStore(factory)
finally:
engine.dispose()
def test_plugin_identity_model_is_registered_by_composition_entry() -> None:
"""组合根加载模型后必须包含插件身份表及物理 ID 唯一约束。"""
load_all_models()
table = PluginIdentityModel.__table__
unique_columns = {
tuple(constraint.columns.keys())
for constraint in table.constraints
if isinstance(constraint, sa.UniqueConstraint)
}
assert table.name == "pluginidentity"
assert ("normalized_plugin_id",) in unique_columns
def test_plugin_identity_store_normalizes_reads_and_rejects_case_duplicate(
identity_store,
) -> None:
"""同一物理目录的大小写别名只能对应数据库中的一行。"""
created = identity_store.compare_and_set(
_identity("DemoPlugin"),
expected_revision=None,
)
assert identity_store.get("DEMOPLUGIN") == created
with pytest.raises(PluginIdentityConflictError):
identity_store.compare_and_set(
_identity("demoplugin"),
expected_revision=None,
)
def test_plugin_identity_store_rejects_stale_revision(identity_store) -> None:
"""旧安装事务不能覆盖已经提交的新身份。"""
original = identity_store.compare_and_set(
_identity(),
expected_revision=None,
)
updated = identity_store.compare_and_set(
replace(
original,
declared_version="2.0.0",
updated_at=NOW + timedelta(seconds=1),
),
expected_revision=original.revision,
)
with pytest.raises(PluginIdentityConflictError):
identity_store.compare_and_set(
replace(
original,
declared_version="stale",
updated_at=NOW + timedelta(seconds=2),
),
expected_revision=original.revision,
)
assert identity_store.get("DemoPlugin") == updated
assert updated.revision == 2
def test_plugin_identity_store_rejects_implicit_source_change(identity_store) -> None:
"""通用条件写不能代替管理员显式换源命令。"""
original = identity_store.compare_and_set(
_identity(),
expected_revision=None,
)
changed = replace(
original,
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
trusted_source_key=THIRD_PARTY_SOURCE,
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
payload_source_type=PluginPayloadSourceType.THIRD_PARTY,
payload_source_key=THIRD_PARTY_SOURCE,
updated_at=NOW + timedelta(seconds=1),
)
with pytest.raises(PluginIdentityConflictError, match="不能改变"):
identity_store.compare_and_set(
changed,
expected_revision=original.revision,
)
assert identity_store.get("DemoPlugin") == original
def test_plugin_identity_store_allows_only_one_same_revision_writer(
identity_store,
) -> None:
"""两个独立会话竞争同一 revision 时必须恰好一个提交成功。"""
original = identity_store.compare_and_set(
_identity(),
expected_revision=None,
)
barrier = threading.Barrier(2)
def update(version: str) -> str:
"""在相同起点并发提交不同版本。"""
barrier.wait()
try:
identity_store.compare_and_set(
replace(
original,
declared_version=version,
updated_at=NOW + timedelta(seconds=1),
),
expected_revision=original.revision,
)
return "applied"
except PluginIdentityConflictError:
return "conflict"
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(update, ("2.0.0", "3.0.0")))
assert sorted(results) == ["applied", "conflict"]
assert identity_store.get("DemoPlugin").revision == 2
def test_local_payload_preserves_trusted_online_binding() -> None:
"""本地开发覆盖只改变载荷事实,不得抹掉可信在线更新仓库。"""
identity = replace(
_identity(),
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="2.0.0-dev",
package_generation="v3",
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),
)
assert identity.trusted_source_key == OFFICIAL_SOURCE
assert identity.payload_source_key is None
assert identity.payload_source_type is PluginPayloadSourceType.LOCAL
def test_first_local_sync_has_a_nonlegacy_identity_basis() -> None:
"""首次本地同步不得被记录为未知存量插件迁移。"""
identity = 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,
bound_at=None,
)
assert identity.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert identity.trusted_source_key is None
def test_online_binding_rejects_local_only_basis() -> None:
"""已绑定在线仓库不能冒用首次本地同步的身份依据。"""
with pytest.raises(ValueError, match="未绑定来源依据"):
replace(_identity(), binding_basis=PluginBindingBasis.LOCAL_ONLY)
@pytest.mark.parametrize(
("field_name", "value"),
(
("bound_at", NOW - timedelta(seconds=1)),
("bound_at", NOW + timedelta(seconds=1)),
("payload_applied_at", NOW - timedelta(seconds=1)),
("payload_applied_at", NOW + timedelta(seconds=1)),
),
)
def test_plugin_identity_rejects_audit_times_outside_record_lifetime(
field_name,
value,
) -> None:
"""来源审计时间必须落在该 revision 的创建和更新时间范围内。"""
with pytest.raises(ValueError, match="审计时间"):
replace(_identity(), **{field_name: value})
def test_unknown_payload_rejects_version_and_receipt_evidence() -> None:
"""存量载荷来源未知时不得保留看似经过安装确认的版本证据。"""
with pytest.raises(ValueError, match="未知载荷不能携带"):
replace(
_identity(),
payload_source_type=PluginPayloadSourceType.UNKNOWN,
payload_source_key=None,
payload_applied_at=None,
)
def test_online_payload_must_match_trusted_source() -> None:
"""在线载荷来源与可信更新仓库不一致时身份必须失败关闭。"""
with pytest.raises(ValueError, match="必须与可信更新来源一致"):
replace(
_identity(),
payload_source_type=PluginPayloadSourceType.THIRD_PARTY,
payload_source_key=THIRD_PARTY_SOURCE,
)
@pytest.mark.parametrize(
("source_type", "source_key"),
(
(TrustedPluginSourceType.OFFICIAL, THIRD_PARTY_SOURCE),
(TrustedPluginSourceType.THIRD_PARTY, OFFICIAL_SOURCE),
),
)
def test_trusted_source_type_must_match_official_repository(
source_type,
source_key,
) -> None:
"""官方仓库键与官方来源类型不能形成相互矛盾的信任事实。"""
with pytest.raises(ValueError, match="官方来源类型"):
replace(
_identity(),
trusted_source_type=source_type,
trusted_source_key=source_key,
)
@pytest.mark.parametrize(
("source_type", "source_key"),
(
(TrustedPluginSourceType.OFFICIAL, THIRD_PARTY_SOURCE),
(TrustedPluginSourceType.THIRD_PARTY, OFFICIAL_SOURCE),
),
)
def test_source_candidate_type_must_match_official_repository(
source_type,
source_key,
) -> None:
"""迁移候选也必须服从与持久化身份相同的来源分类合同。"""
with pytest.raises(ValueError, match="官方来源类型"):
PluginSourceCandidate(source_type, source_key)
@pytest.mark.parametrize(
"plugin_id",
(" DemoPlugin", "DemoPlugin ", "A" * 129),
)
def test_plugin_identity_rejects_noncanonical_physical_id(plugin_id) -> None:
"""物理 ID 不得靠静默裁剪或数据库方言差异改变身份。"""
with pytest.raises(ValueError, match="插件 ID"):
_identity(plugin_id)
@pytest.mark.parametrize(
("field_name", "value", "message"),
(
("declared_version", "v" * 65, "插件声明版本"),
),
)
def test_plugin_identity_rejects_values_longer_than_database_columns(
field_name,
value,
message,
) -> None:
"""SQLite 与 PostgreSQL 必须在进入持久化前共享相同长度边界。"""
with pytest.raises(ValueError, match=message):
replace(_identity(), **{field_name: value})
def test_legacy_official_candidate_binds_updates_but_not_payload() -> None:
"""官方默认只建立未来更新绑定,不能冒充存量载荷来源。"""
identity = plan_legacy_plugin_identity(
plugin_id="DemoPlugin",
market_availability=PluginMarketAvailability.AVAILABLE,
online_candidates=(
PluginSourceCandidate(
TrustedPluginSourceType.THIRD_PARTY,
THIRD_PARTY_SOURCE,
),
PluginSourceCandidate(
TrustedPluginSourceType.OFFICIAL,
OFFICIAL_SOURCE,
),
),
is_virtual_instance=False,
now=NOW,
)
assert identity.trusted_source_type is TrustedPluginSourceType.OFFICIAL
assert identity.trusted_source_key == OFFICIAL_SOURCE
assert identity.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
assert identity.payload_source_type is PluginPayloadSourceType.UNKNOWN
assert identity.declared_version is None
def test_legacy_single_third_party_candidate_uses_tofu() -> None:
"""唯一第三方候选可建立一次性更新绑定,但载荷仍保持未知。"""
identity = plan_legacy_plugin_identity(
plugin_id="DemoPlugin",
market_availability=PluginMarketAvailability.AVAILABLE,
online_candidates=(
PluginSourceCandidate(
TrustedPluginSourceType.THIRD_PARTY,
THIRD_PARTY_SOURCE,
),
),
is_virtual_instance=False,
now=NOW,
)
assert identity.binding_basis is PluginBindingBasis.TOFU
assert identity.trusted_source_key == THIRD_PARTY_SOURCE
assert identity.payload_source_type is PluginPayloadSourceType.UNKNOWN
@pytest.mark.parametrize(
("availability", "candidates"),
(
(PluginMarketAvailability.UNAVAILABLE, ()),
(PluginMarketAvailability.AVAILABLE, ()),
(
PluginMarketAvailability.AVAILABLE,
(
PluginSourceCandidate(
TrustedPluginSourceType.THIRD_PARTY,
"github:first/plugins",
),
PluginSourceCandidate(
TrustedPluginSourceType.THIRD_PARTY,
"github:second/plugins",
),
),
),
),
)
def test_legacy_unavailable_empty_or_ambiguous_market_stays_unbound(
availability,
candidates,
) -> None:
"""离线、无候选和多候选必须保持可区分输入,但均不得猜测绑定。"""
identity = plan_legacy_plugin_identity(
plugin_id="DemoPlugin",
market_availability=availability,
online_candidates=candidates,
is_virtual_instance=False,
now=NOW,
)
assert identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN
assert identity.trusted_source_key is None
assert identity.binding_basis is PluginBindingBasis.LEGACY_UNBOUND
def test_virtual_instance_does_not_create_independent_plugin_identity() -> None:
"""有效 PluginInstances 派生实例只继承物理宿主身份。"""
assert plan_legacy_plugin_identity(
plugin_id="DemoPluginWork",
market_availability=PluginMarketAvailability.AVAILABLE,
online_candidates=(
PluginSourceCandidate(
TrustedPluginSourceType.OFFICIAL,
OFFICIAL_SOURCE,
),
),
is_virtual_instance=True,
now=NOW,
) is None