"""Tests for services.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 unittest.mock import MagicMock, patch
import pytest
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginVerification
from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import PluginInstallationScope
from services.plugin.plugin_service import PluginService
from tests.unit_tests.services.plugin.conftest import make_features
class TestFetchLatestPluginVersion:
@patch("services.plugin.plugin_service.marketplace")
@patch("services.plugin.plugin_service.redis_client")
def test_returns_cached_version(self, mock_redis, mock_marketplace):
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"])
assert result["p1"].version == "1.0.0"
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
@patch("services.plugin.plugin_service.marketplace")
@patch("services.plugin.plugin_service.redis_client")
def test_fetches_from_marketplace_on_cache_miss(self, mock_redis, mock_marketplace):
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"])
assert result["p1"].version == "2.0.0"
mock_redis.setex.assert_called_once()
@patch("services.plugin.plugin_service.marketplace")
@patch("services.plugin.plugin_service.redis_client")
def test_returns_none_for_unknown_plugin(self, mock_redis, mock_marketplace):
mock_redis.get.return_value = None
mock_marketplace.batch_fetch_plugin_manifests.return_value = []
result = PluginService.fetch_latest_plugin_version(["unknown"])
assert result["unknown"] is None
@patch("services.plugin.plugin_service.marketplace")
@patch("services.plugin.plugin_service.redis_client")
def test_handles_marketplace_exception_gracefully(self, mock_redis, mock_marketplace):
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("services.plugin.plugin_service.FeatureService")
def test_raises_when_restricted(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(restrict_to_marketplace=True)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_marketplace_only_permission()
@patch("services.plugin.plugin_service.FeatureService")
def test_passes_when_not_restricted(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(restrict_to_marketplace=False)
PluginService._check_marketplace_only_permission() # should not raise
class TestCheckPluginInstallationScope:
@patch("services.plugin.plugin_service.FeatureService")
def test_official_only_allows_langgenius(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
PluginService._check_plugin_installation_scope(verification) # should not raise
@patch("services.plugin.plugin_service.FeatureService")
def test_official_only_rejects_third_party(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_plugin_installation_scope(None)
@patch("services.plugin.plugin_service.FeatureService")
def test_official_and_partners_allows_partner(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Partner
PluginService._check_plugin_installation_scope(verification) # should not raise
@patch("services.plugin.plugin_service.FeatureService")
def test_official_and_partners_rejects_none(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
)
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_plugin_installation_scope(None)
@patch("services.plugin.plugin_service.FeatureService")
def test_none_scope_always_raises(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(scope=PluginInstallationScope.NONE)
verification = MagicMock()
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
with pytest.raises(PluginInstallationForbiddenError):
PluginService._check_plugin_installation_scope(verification)
@patch("services.plugin.plugin_service.FeatureService")
def test_all_scope_passes_any(self, mock_fs):
mock_fs.get_system_features.return_value = make_features(scope=PluginInstallationScope.ALL)
PluginService._check_plugin_installation_scope(None) # should not raise
class TestGetPluginIconUrl:
@patch("services.plugin.plugin_service.dify_config")
def test_constructs_url_with_params(self, mock_config):
mock_config.CONSOLE_API_URL = "https://console.example.com"
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("services.plugin.plugin_service.PluginAssetManager")
def test_returns_bytes_and_guessed_mime(self, mock_asset_cls):
mock_asset_cls.return_value.fetch_asset.return_value = b""
data, mime = PluginService.get_asset("t1", "icon.svg")
assert data == b""
assert "svg" in mime
@patch("services.plugin.plugin_service.PluginAssetManager")
def test_fallback_to_octet_stream_for_unknown(self, mock_asset_cls):
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("services.plugin.plugin_service.PluginInstaller")
def test_returns_true_when_verified(self, mock_installer_cls):
mock_installer_cls.return_value.fetch_plugin_manifest.return_value.verified = True
assert PluginService.is_plugin_verified("t1", "uid-1") is True
@patch("services.plugin.plugin_service.PluginInstaller")
def test_returns_false_on_exception(self, mock_installer_cls):
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:
@patch("services.plugin.plugin_service.dify_config")
def test_raises_when_marketplace_disabled(self, mock_config):
mock_config.MARKETPLACE_ENABLED = False
with pytest.raises(ValueError, match="marketplace is not enabled"):
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
@patch("services.plugin.plugin_service.dify_config")
def test_raises_when_same_identifier(self, mock_config):
mock_config.MARKETPLACE_ENABLED = True
with pytest.raises(ValueError, match="same plugin"):
PluginService.upgrade_plugin_with_marketplace("t1", "same-uid", "same-uid")
@patch("services.plugin.plugin_service.marketplace")
@patch("services.plugin.plugin_service.FeatureService")
@patch("services.plugin.plugin_service.PluginInstaller")
@patch("services.plugin.plugin_service.dify_config")
def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_system_features.return_value = make_features()
installer = mock_installer_cls.return_value
installer.fetch_plugin_manifest.return_value = MagicMock() # no exception = already installed
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("services.plugin.plugin_service.download_plugin_pkg")
@patch("services.plugin.plugin_service.FeatureService")
@patch("services.plugin.plugin_service.PluginInstaller")
@patch("services.plugin.plugin_service.dify_config")
def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_system_features.return_value = make_features()
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()
class TestUpgradePluginWithGithub:
@patch("services.plugin.plugin_service.FeatureService")
@patch("services.plugin.plugin_service.PluginInstaller")
def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls, mock_fs):
mock_fs.get_system_features.return_value = make_features()
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("services.plugin.plugin_service.FeatureService")
@patch("services.plugin.plugin_service.PluginInstaller")
def test_runs_permission_and_scope_checks(self, mock_installer_cls, mock_fs):
mock_fs.get_system_features.return_value = make_features()
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:
@patch("services.plugin.plugin_service.dify_config")
def test_raises_when_marketplace_disabled(self, mock_config):
mock_config.MARKETPLACE_ENABLED = False
with pytest.raises(ValueError, match="marketplace is not enabled"):
PluginService.install_from_marketplace_pkg("t1", ["uid-1"])
@patch("services.plugin.plugin_service.download_plugin_pkg")
@patch("services.plugin.plugin_service.FeatureService")
@patch("services.plugin.plugin_service.PluginInstaller")
@patch("services.plugin.plugin_service.dify_config")
def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_system_features.return_value = make_features()
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"] # uses response uid, not input
@patch("services.plugin.plugin_service.FeatureService")
@patch("services.plugin.plugin_service.PluginInstaller")
@patch("services.plugin.plugin_service.dify_config")
def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls, mock_fs):
mock_config.MARKETPLACE_ENABLED = True
mock_fs.get_system_features.return_value = make_features()
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"] # uses original uid
class TestUninstall:
@patch("services.plugin.plugin_service.PluginInstaller")
def test_direct_uninstall_when_plugin_not_found(self, mock_installer_cls):
installer = mock_installer_cls.return_value
installer.list_plugins.return_value = []
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("services.plugin.plugin_service.db")
@patch("services.plugin.plugin_service.PluginInstaller")
def test_cleans_credentials_when_plugin_found(self, mock_installer_cls, mock_db):
plugin = MagicMock()
plugin.installation_id = "install-1"
plugin.plugin_id = "org/myplugin"
installer = mock_installer_cls.return_value
installer.list_plugins.return_value = [plugin]
installer.uninstall.return_value = True
# Mock Session context manager
mock_session = MagicMock()
mock_db.engine = MagicMock()
mock_session.scalars.return_value.all.return_value = [] # no credentials found
with patch("services.plugin.plugin_service.Session") as mock_session_cls:
mock_session_cls.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_session_cls.return_value.__exit__ = MagicMock(return_value=False)
result = PluginService.uninstall("t1", "install-1")
assert result is True
installer.uninstall.assert_called_once()