feat(plugin): 建立可信来源准入与安装恢复 (#6462)

This commit is contained in:
InfinityPacer
2026-08-26 07:52:00 +08:00
committed by GitHub
parent 3272f72823
commit 71db425c07
58 changed files with 11471 additions and 1493 deletions
+289
View File
@@ -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