Files
dify/api/tests/unit_tests/services/errors/test_plugin.py
T

627 lines
27 KiB
Python

"""Unit tests for core.plugin.plugin_service.PluginService.
Covers: version caching with Redis, install permission/scope gates,
icon URL construction, asset retrieval with MIME guessing, plugin
verification, marketplace upgrade flows, and uninstall with credential cleanup.
"""
from __future__ import annotations
from collections.abc import Callable, Iterator
from typing import cast
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import Table, select
from sqlalchemy.orm import Session
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginVerification
from core.plugin.plugin_service import PluginService
from enums import DeploymentEdition
from models import ProviderType
from models.engine import db
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
from services.entities.feature_entities import (
PluginInstallationPermissionModel,
PluginInstallationScope,
)
from services.errors.plugin import PluginInstallationForbiddenError
from tests.unit_tests.services.plugin.conftest import mock_features as mock_features # noqa: PLC0414
from tests.unit_tests.services.plugin.conftest import mock_installer as mock_installer # noqa: PLC0414
def _make_permission(
restrict_to_marketplace: bool = False,
scope: PluginInstallationScope = PluginInstallationScope.ALL,
) -> PluginInstallationPermissionModel:
return PluginInstallationPermissionModel(
restrict_to_marketplace_only=restrict_to_marketplace,
plugin_installation_scope=scope,
)
@pytest.fixture
def plugin_db() -> Iterator[Session]:
"""Provide the production database extension with isolated SQLite plugin tables."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
for model in (ProviderCredential, Provider, TenantPreferredModelProvider):
table = model.__table__
assert isinstance(table, Table)
table.create(db.engine)
with Session(db.engine, expire_on_commit=False) as session:
yield session
@pytest.fixture(autouse=True)
def _plugin_config(config_overrides: Callable[..., None]) -> None:
config_overrides(
CONSOLE_API_URL="https://console.example.com",
MARKETPLACE_ENABLED=True,
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
)
class TestFetchLatestPluginVersion:
@patch("core.plugin.plugin_service.marketplace")
@patch("core.plugin.plugin_service.redis_client")
def test_returns_cached_version(self, mock_redis: MagicMock, mock_marketplace: MagicMock) -> None:
cached_json = PluginService.LatestPluginCache(
plugin_id="p1",
version="1.0.0",
unique_identifier="uid-1",
status="active",
deprecated_reason="",
alternative_plugin_id="",
).model_dump_json()
mock_redis.get.return_value = cached_json
result = PluginService.fetch_latest_plugin_version(["p1"])
cached = result["p1"]
assert cached is not None
assert cached.version == "1.0.0"
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
@patch("core.plugin.plugin_service.marketplace")
@patch("core.plugin.plugin_service.redis_client")
def test_fetches_from_marketplace_on_cache_miss(self, mock_redis: MagicMock, mock_marketplace: MagicMock) -> None:
mock_redis.get.return_value = None
manifest = MagicMock()
manifest.plugin_id = "p1"
manifest.latest_version = "2.0.0"
manifest.latest_package_identifier = "uid-2"
manifest.status = "active"
manifest.deprecated_reason = ""
manifest.alternative_plugin_id = ""
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
result = PluginService.fetch_latest_plugin_version(["p1"])
fetched = result["p1"]
assert fetched is not None
assert fetched.version == "2.0.0"
mock_redis.setex.assert_called_once()
@patch("core.plugin.plugin_service.marketplace")
@patch("core.plugin.plugin_service.redis_client")
def test_returns_none_for_unknown_plugin(self, mock_redis: MagicMock, mock_marketplace: MagicMock) -> None:
mock_redis.get.return_value = None
no_manifests: list[object] = []
mock_marketplace.batch_fetch_plugin_manifests.return_value = no_manifests
result = PluginService.fetch_latest_plugin_version(["unknown"])
assert result["unknown"] is None
@patch("core.plugin.plugin_service.marketplace")
@patch("core.plugin.plugin_service.redis_client")
def test_handles_marketplace_exception_gracefully(self, mock_redis: MagicMock, mock_marketplace: MagicMock) -> None:
mock_redis.get.return_value = None
mock_marketplace.batch_fetch_plugin_manifests.side_effect = RuntimeError("network error")
result = PluginService.fetch_latest_plugin_version(["p1"])
assert result == {}
class TestCheckMarketplaceOnlyPermission:
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_raises_when_restricted(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_marketplace_only_permission()
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_passes_when_not_restricted(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False)
PluginService._check_marketplace_only_permission() # should not raise
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_raises_when_scope_denies_all(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
with pytest.raises(PluginInstallationForbiddenError, match="not allowed"):
PluginService._check_marketplace_only_permission()
class TestCheckPluginInstallationScope:
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_official_only_allows_langgenius(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_ONLY
)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
PluginService._check_plugin_installation_scope(verification) # should not raise
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_official_only_rejects_third_party(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_ONLY
)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_plugin_installation_scope(None)
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_official_and_partners_allows_partner(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Partner
PluginService._check_plugin_installation_scope(verification) # should not raise
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_official_and_partners_rejects_none(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_plugin_installation_scope(None)
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_none_scope_always_raises(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_plugin_installation_scope(verification)
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_all_scope_passes_any(self, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL)
PluginService._check_plugin_installation_scope(None) # should not raise
@patch("core.plugin.plugin_service.SystemFeatureService")
def test_unknown_scope_always_raises(self, mock_fs: MagicMock) -> None:
permission = _make_permission()
permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope")
mock_fs.get_plugin_installation_permission.return_value = permission
with pytest.raises(PluginInstallationForbiddenError, match="policy is invalid"):
PluginService._check_plugin_installation_scope(None)
class TestGetPluginIconUrl:
def test_constructs_url_with_params(self) -> None:
url = PluginService.get_plugin_icon_url("tenant-1", "icon.svg")
assert "tenant_id=tenant-1" in url
assert "filename=icon.svg" in url
assert "/plugin/icon" in url
class TestGetAsset:
@patch("core.plugin.plugin_service.PluginAssetManager")
def test_returns_bytes_and_guessed_mime(self, mock_asset_cls: MagicMock) -> None:
mock_asset_cls.return_value.fetch_asset.return_value = b"<svg/>"
data, mime = PluginService.get_asset("t1", "icon.svg")
assert data == b"<svg/>"
assert "svg" in mime
@patch("core.plugin.plugin_service.PluginAssetManager")
def test_fallback_to_octet_stream_for_unknown(self, mock_asset_cls: MagicMock) -> None:
mock_asset_cls.return_value.fetch_asset.return_value = b"\x00"
_, mime = PluginService.get_asset("t1", "unknown_file")
assert mime == "application/octet-stream"
class TestIsPluginVerified:
@patch("core.plugin.plugin_service.PluginInstaller")
def test_returns_true_when_verified(self, mock_installer_cls: MagicMock) -> None:
mock_installer_cls.return_value.fetch_plugin_manifest.return_value.verified = True
assert PluginService.is_plugin_verified("t1", "uid-1") is True
@patch("core.plugin.plugin_service.PluginInstaller")
def test_returns_false_on_exception(self, mock_installer_cls: MagicMock) -> None:
mock_installer_cls.return_value.fetch_plugin_manifest.side_effect = RuntimeError("not found")
assert PluginService.is_plugin_verified("t1", "uid-1") is False
class TestUpgradePluginWithMarketplace:
def test_raises_when_marketplace_disabled(self, config_overrides: Callable[..., None]) -> None:
config_overrides(MARKETPLACE_ENABLED=False)
with pytest.raises(ValueError, match="marketplace is not enabled"):
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
def test_raises_when_same_identifier(self) -> None:
with pytest.raises(ValueError, match="same plugin"):
PluginService.upgrade_plugin_with_marketplace("t1", "same-uid", "same-uid")
@patch("core.plugin.plugin_service.marketplace")
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_skips_download_when_already_installed(
self, mock_installer_cls: MagicMock, mock_fs: MagicMock, mock_marketplace: MagicMock
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock()
installer.upgrade_plugin.return_value = MagicMock()
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
mock_marketplace.record_install_plugin_event.assert_called_once_with("new-uid")
installer.upgrade_plugin.assert_called_once()
@patch("core.plugin.plugin_service.download_plugin_pkg")
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_downloads_when_not_installed(
self, mock_installer_cls: MagicMock, mock_fs: MagicMock, mock_download: MagicMock
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
mock_download.return_value = b"pkg-bytes"
upload_resp = MagicMock()
upload_resp.verification = None
installer.upload_pkg.return_value = upload_resp
installer.upgrade_plugin.return_value = MagicMock()
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
mock_download.assert_called_once_with("new-uid")
installer.upload_pkg.assert_called_once()
@pytest.mark.parametrize(
"scope",
[PluginInstallationScope.OFFICIAL_ONLY, PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS],
)
@patch("core.plugin.plugin_service.download_plugin_pkg")
@patch("core.plugin.plugin_service.marketplace")
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_rejects_cached_pkg_outside_scope(
self,
mock_installer_cls: MagicMock,
mock_fs: MagicMock,
mock_marketplace: MagicMock,
mock_download: MagicMock,
scope: PluginInstallationScope,
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=scope)
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock()
decode_resp = MagicMock()
decode_resp.verification.authorized_category = PluginVerification.AuthorizedCategory.Community
installer.decode_plugin_from_identifier.return_value = decode_resp
with pytest.raises(PluginInstallationForbiddenError):
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
installer.upgrade_plugin.assert_not_called()
mock_marketplace.record_install_plugin_event.assert_not_called()
# the rejection must not fall through to the download branch and cache the pkg again
mock_download.assert_not_called()
installer.upload_pkg.assert_not_called()
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_rejects_before_touching_daemon_when_scope_is_none(
self, mock_installer_cls: MagicMock, mock_fs: MagicMock
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
installer = mock_installer_cls.return_value
with pytest.raises(PluginInstallationForbiddenError):
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
installer.fetch_plugin_manifest.assert_not_called()
installer.upgrade_plugin.assert_not_called()
@patch("core.plugin.plugin_service.marketplace")
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_allows_cached_official_pkg_under_official_only(
self, mock_installer_cls: MagicMock, mock_fs: MagicMock, mock_marketplace: MagicMock
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_ONLY
)
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock()
decode_resp = MagicMock()
decode_resp.verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
installer.decode_plugin_from_identifier.return_value = decode_resp
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
mock_marketplace.record_install_plugin_event.assert_called_once_with("new-uid")
installer.upgrade_plugin.assert_called_once()
class TestUpgradePluginWithGithub:
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_checks_marketplace_permission_and_delegates(
self, mock_installer_cls: MagicMock, mock_fs: MagicMock
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
installer = mock_installer_cls.return_value
installer.upgrade_plugin.return_value = MagicMock()
PluginService.upgrade_plugin_with_github("t1", "old-uid", "new-uid", "org/repo", "v1", "pkg.difypkg")
installer.upgrade_plugin.assert_called_once()
call_args = installer.upgrade_plugin.call_args
assert call_args[0][3] == PluginInstallationSource.Github
class TestUploadPkg:
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
upload_resp = MagicMock()
upload_resp.verification = None
mock_installer_cls.return_value.upload_pkg.return_value = upload_resp
result = PluginService.upload_pkg("t1", b"pkg-bytes")
assert result is upload_resp
class TestInstallFromMarketplacePkg:
def test_raises_when_marketplace_disabled(self, config_overrides: Callable[..., None]) -> None:
config_overrides(MARKETPLACE_ENABLED=False)
with pytest.raises(ValueError, match="marketplace is not enabled"):
PluginService.install_from_marketplace_pkg("t1", ["uid-1"])
@patch("core.plugin.plugin_service.download_plugin_pkg")
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_downloads_when_not_cached(
self, mock_installer_cls: MagicMock, mock_fs: MagicMock, mock_download: MagicMock
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
mock_download.return_value = b"pkg"
upload_resp = MagicMock()
upload_resp.verification = None
upload_resp.unique_identifier = "resolved-uid"
installer.upload_pkg.return_value = upload_resp
installer.install_from_identifiers.return_value = "task-id"
result = PluginService.install_from_marketplace_pkg("t1", ["uid-1"])
assert result == "task-id"
installer.install_from_identifiers.assert_called_once()
call_args = installer.install_from_identifiers.call_args[0]
assert call_args[1] == ["resolved-uid"]
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_uses_cached_when_already_downloaded(self, mock_installer_cls: MagicMock, mock_fs: MagicMock) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock()
decode_resp = MagicMock()
decode_resp.verification = None
installer.decode_plugin_from_identifier.return_value = decode_resp
installer.install_from_identifiers.return_value = "task-id"
PluginService.install_from_marketplace_pkg("t1", ["uid-1"])
installer.install_from_identifiers.assert_called_once()
call_args = installer.install_from_identifiers.call_args[0]
assert call_args[1] == ["uid-1"]
@patch("core.plugin.plugin_service.download_plugin_pkg")
@patch("core.plugin.plugin_service.SystemFeatureService")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_rejects_cached_pkg_outside_scope(
self, mock_installer_cls: MagicMock, mock_fs: MagicMock, mock_download: MagicMock
) -> None:
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
scope=PluginInstallationScope.OFFICIAL_ONLY
)
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock()
decode_resp = MagicMock()
decode_resp.verification.authorized_category = PluginVerification.AuthorizedCategory.Community
installer.decode_plugin_from_identifier.return_value = decode_resp
with pytest.raises(PluginInstallationForbiddenError):
PluginService.install_from_marketplace_pkg("t1", ["uid-1"])
installer.install_from_identifiers.assert_not_called()
# the rejection must not fall through to the download branch and cache the pkg again
mock_download.assert_not_called()
installer.upload_pkg.assert_not_called()
class TestUninstall:
@patch("core.plugin.plugin_service.PluginInstaller")
def test_direct_uninstall_when_plugin_not_found(self, mock_installer_cls: MagicMock) -> None:
installer = mock_installer_cls.return_value
no_plugins: list[object] = []
installer.list_plugins.return_value = no_plugins
installer.uninstall.return_value = True
result = PluginService.uninstall("t1", "install-1")
assert result is True
installer.uninstall.assert_called_once_with("t1", "install-1")
@patch("core.plugin.plugin_service.PluginInstaller")
def test_cleans_credentials_when_plugin_found(self, mock_installer_cls: MagicMock, plugin_db: Session) -> None:
tenant_id = str(uuid4())
plugin_id = "org/myplugin"
provider_name = f"{plugin_id}/model-provider"
credential = ProviderCredential(
tenant_id=tenant_id,
provider_name=provider_name,
credential_name="default",
encrypted_config="{}",
)
plugin_db.add(credential)
plugin_db.flush()
credential_id = credential.id
provider = Provider(
tenant_id=tenant_id,
provider_name=provider_name,
credential_id=credential_id,
)
plugin_db.add(provider)
plugin_db.flush()
provider_id = provider.id
pref = TenantPreferredModelProvider(
tenant_id=tenant_id,
provider_name=provider_name,
preferred_provider_type=ProviderType.CUSTOM,
)
plugin_db.add(pref)
plugin_db.commit()
plugin = MagicMock()
plugin.installation_id = "install-1"
plugin.plugin_id = plugin_id
installer = mock_installer_cls.return_value
installer.list_plugins.return_value = [plugin]
installer.uninstall.return_value = True
result = PluginService.uninstall(tenant_id, "install-1")
assert result is True
installer.uninstall.assert_called_once()
plugin_db.expire_all()
remaining_creds = plugin_db.scalars(
select(ProviderCredential).where(ProviderCredential.id == credential_id)
).all()
assert len(remaining_creds) == 0
updated_provider = plugin_db.get(Provider, provider_id)
assert updated_provider is not None
assert updated_provider.credential_id is None
remaining_prefs = plugin_db.scalars(
select(TenantPreferredModelProvider).where(
TenantPreferredModelProvider.tenant_id == tenant_id,
TenantPreferredModelProvider.provider_name == provider_name,
)
).all()
assert len(remaining_prefs) == 0
@patch("core.plugin.plugin_service.PluginInstaller")
def test_preserves_credentials_when_replacing_plugin(
self, mock_installer_cls: MagicMock, plugin_db: Session
) -> None:
tenant_id = str(uuid4())
plugin_id = "org/myplugin"
provider_name = f"{plugin_id}/model-provider"
credential = ProviderCredential(
tenant_id=tenant_id,
provider_name=provider_name,
credential_name="default",
encrypted_config="{}",
)
plugin_db.add(credential)
plugin_db.flush()
provider = Provider(
tenant_id=tenant_id,
provider_name=provider_name,
credential_id=credential.id,
)
plugin_db.add(provider)
preferred_provider = TenantPreferredModelProvider(
tenant_id=tenant_id,
provider_name=provider_name,
preferred_provider_type=ProviderType.CUSTOM,
)
plugin_db.add(preferred_provider)
plugin_db.commit()
plugin = MagicMock(installation_id="install-1", plugin_id=plugin_id)
installer = mock_installer_cls.return_value
installer.list_plugins.return_value = [plugin]
installer.uninstall.return_value = True
result = PluginService.uninstall(tenant_id, "install-1", preserve_credentials=True)
assert result is True
plugin_db.expire_all()
assert plugin_db.get(ProviderCredential, credential.id) is not None
stored_provider = plugin_db.get(Provider, provider.id)
assert stored_provider is not None
assert stored_provider.credential_id == credential.id
assert plugin_db.get(TenantPreferredModelProvider, preferred_provider.id) is not None
@patch("core.plugin.plugin_service.PluginInstaller")
def test_preserves_credentials_when_daemon_uninstall_fails(
self, mock_installer_cls: MagicMock, plugin_db: Session
) -> None:
tenant_id = str(uuid4())
plugin_id = "org/myplugin"
credential = ProviderCredential(
tenant_id=tenant_id,
provider_name=f"{plugin_id}/model-provider",
credential_name="default",
encrypted_config="{}",
)
plugin_db.add(credential)
plugin_db.commit()
plugin = MagicMock(installation_id="install-1", plugin_id=plugin_id)
installer = mock_installer_cls.return_value
installer.list_plugins.return_value = [plugin]
installer.uninstall.return_value = False
result = PluginService.uninstall(tenant_id, "install-1")
assert result is False
plugin_db.expire_all()
assert plugin_db.get(ProviderCredential, credential.id) is not None