mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
Co-authored-by: Felix Shi <204130665+shi-felix@users.noreply.github.com>
627 lines
27 KiB
Python
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
|