mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
"""插件安装 journal 启动重放与阻断边界测试。"""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.recovery import (
|
||||
PluginInstallationRecoveryError,
|
||||
PluginInstallationRecoveryService,
|
||||
)
|
||||
from app.application.plugin.transaction import (
|
||||
PluginInstallationPhase,
|
||||
PluginInstallationRecord,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
RECEIPT = "sha256:" + "1" * 64
|
||||
|
||||
|
||||
def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity:
|
||||
"""构造一份已提交载荷身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="2.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=True,
|
||||
payload_receipt=receipt,
|
||||
revision=revision,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _record(
|
||||
*,
|
||||
phase: PluginInstallationPhase,
|
||||
transaction_id: str = "txn-demo",
|
||||
) -> PluginInstallationRecord:
|
||||
"""构造 PREPARED 或 COMMITTED 恢复记录。"""
|
||||
committed = phase is PluginInstallationPhase.COMMITTED
|
||||
return PluginInstallationRecord(
|
||||
transaction_id=transaction_id,
|
||||
plugin_id="DemoPlugin",
|
||||
phase=phase,
|
||||
membership_before=True,
|
||||
membership_target=True if committed else None,
|
||||
identity_before_revision=1,
|
||||
identity_target_revision=2 if committed else None,
|
||||
package_existed=True,
|
||||
persistent_backup_existed=True,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
class _Persistence:
|
||||
"""保存恢复测试所需 journal、身份和删除故障。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
records: list[PluginInstallationRecord],
|
||||
*,
|
||||
identity: PluginIdentity | None = None,
|
||||
delete_errors: list[Exception | None] | None = None,
|
||||
) -> None:
|
||||
self.records = {record.transaction_id: record for record in records}
|
||||
self.identity = identity
|
||||
self.delete_errors = list(delete_errors or [])
|
||||
self.delete_calls: list[tuple[str, PluginInstallationPhase]] = []
|
||||
|
||||
async def list_installations(self) -> list[PluginInstallationRecord]:
|
||||
"""按创建顺序返回当前 journal。"""
|
||||
return list(self.records.values())
|
||||
|
||||
async def get_identity(self, _plugin_id: str) -> PluginIdentity | None:
|
||||
"""返回已提交身份。"""
|
||||
return self.identity
|
||||
|
||||
async def delete_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase 删除 journal,并可注入一次性错误。"""
|
||||
self.delete_calls.append((transaction_id, expected_phase))
|
||||
if self.delete_errors:
|
||||
error = self.delete_errors.pop(0)
|
||||
if error is not None:
|
||||
raise error
|
||||
record = self.records.get(transaction_id)
|
||||
if record is None:
|
||||
return False
|
||||
assert record.phase is expected_phase
|
||||
del self.records[transaction_id]
|
||||
return True
|
||||
|
||||
|
||||
def _packages(**overrides):
|
||||
"""构造恢复服务消费的单一包事务端口。"""
|
||||
checkpoint = SimpleNamespace(
|
||||
plugin_existed=True,
|
||||
persistent_backup_existed=True,
|
||||
)
|
||||
values = {
|
||||
"restore_checkpoint": Mock(return_value=checkpoint),
|
||||
"async_restore": AsyncMock(),
|
||||
"async_cleanup": AsyncMock(),
|
||||
"async_committed_payload_receipt": AsyncMock(return_value=RECEIPT),
|
||||
"async_finalize_persistent_backup": AsyncMock(),
|
||||
"async_commit": AsyncMock(),
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepared_replay_restores_before_releasing_journal() -> None:
|
||||
"""PREPARED 必须先恢复旧载荷,再删除 journal 和恢复材料。"""
|
||||
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
|
||||
packages = _packages()
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
result = await service.replay()
|
||||
|
||||
assert result.restored == 1
|
||||
assert persistence.records == {}
|
||||
packages.async_restore.assert_awaited_once()
|
||||
packages.async_cleanup.assert_awaited_once()
|
||||
assert persistence.delete_calls == [
|
||||
("txn-demo", PluginInstallationPhase.PREPARED)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepared_delete_failure_keeps_replayable_journal() -> None:
|
||||
"""恢复完成但 journal 删除失败时,下次启动仍可幂等重放。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.PREPARED)],
|
||||
delete_errors=[RuntimeError("database unavailable"), None],
|
||||
)
|
||||
packages = _packages()
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationRecoveryError, match="未提交安装恢复失败"):
|
||||
await service.replay()
|
||||
assert "txn-demo" in persistence.records
|
||||
packages.async_cleanup.assert_not_awaited()
|
||||
|
||||
result = await service.replay()
|
||||
|
||||
assert result.restored == 1
|
||||
assert persistence.records == {}
|
||||
assert packages.async_restore.await_count == 2
|
||||
packages.async_cleanup.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepared_restore_failure_blocks_plugin_import() -> None:
|
||||
"""旧载荷无法恢复时必须保留 journal,并让启动阶段失败。"""
|
||||
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
|
||||
packages = _packages(
|
||||
async_restore=AsyncMock(side_effect=RuntimeError("snapshot missing"))
|
||||
)
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationRecoveryError, match="snapshot missing"):
|
||||
await service.replay()
|
||||
|
||||
assert "txn-demo" in persistence.records
|
||||
assert persistence.delete_calls == []
|
||||
packages.async_cleanup.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_committed_replay_verifies_identity_and_receipt_before_cleanup() -> None:
|
||||
"""COMMITTED 只在身份 revision 和载荷收据一致时完成幂等收尾。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.COMMITTED)],
|
||||
identity=_identity(),
|
||||
)
|
||||
packages = _packages()
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
result = await service.replay()
|
||||
|
||||
assert result.finalized == 1
|
||||
assert persistence.records == {}
|
||||
packages.async_committed_payload_receipt.assert_awaited_once()
|
||||
packages.async_finalize_persistent_backup.assert_awaited_once()
|
||||
packages.async_commit.assert_awaited_once()
|
||||
assert persistence.delete_calls == [
|
||||
("txn-demo", PluginInstallationPhase.COMMITTED)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("identity", "receipt", "message"),
|
||||
[
|
||||
(replace(_identity(), revision=3), RECEIPT, "身份与安装 journal 不一致"),
|
||||
(_identity(), "sha256:" + "2" * 64, "载荷收据不一致"),
|
||||
],
|
||||
)
|
||||
async def test_committed_fact_mismatch_blocks_plugin_import(
|
||||
identity: PluginIdentity,
|
||||
receipt: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""已提交数据库事实与可恢复载荷不一致时不得继续加载插件。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.COMMITTED)],
|
||||
identity=identity,
|
||||
)
|
||||
packages = _packages(
|
||||
async_committed_payload_receipt=AsyncMock(return_value=receipt)
|
||||
)
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationRecoveryError, match=message):
|
||||
await service.replay()
|
||||
|
||||
assert "txn-demo" in persistence.records
|
||||
packages.async_finalize_persistent_backup.assert_not_awaited()
|
||||
packages.async_commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_committed_cleanup_failure_is_retried_without_rollback() -> None:
|
||||
"""COMMITTED 收尾失败只保留 journal,下一次启动继续清理。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.COMMITTED)],
|
||||
identity=_identity(),
|
||||
)
|
||||
package_commit = AsyncMock(
|
||||
side_effect=[RuntimeError("snapshot busy"), None]
|
||||
)
|
||||
packages = _packages(async_commit=package_commit)
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
first = await service.replay()
|
||||
|
||||
assert first.cleanup_pending == 1
|
||||
assert "txn-demo" in persistence.records
|
||||
assert persistence.delete_calls == []
|
||||
|
||||
second = await service.replay()
|
||||
|
||||
assert second.finalized == 1
|
||||
assert persistence.records == {}
|
||||
assert packages.async_finalize_persistent_backup.await_count == 2
|
||||
assert package_commit.await_count == 2
|
||||
Reference in New Issue
Block a user