abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
831 lines
28 KiB
Python
831 lines
28 KiB
Python
"""Tests for the Marketplace plugin — service and route layers.
|
|
|
|
Uses AsyncMock for all DB operations, httpx, and PluginSignature.
|
|
No real DB or HTTP connections required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import uuid
|
|
import zipfile
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.plugins.builtins.marketplace.models import MarketplaceListing
|
|
from app.plugins.builtins.marketplace.routes import router as marketplace_router
|
|
from app.plugins.builtins.marketplace.services import (
|
|
download_plugin,
|
|
fetch_listings,
|
|
get_categories,
|
|
get_listing_by_name,
|
|
install_plugin,
|
|
verify_plugin,
|
|
)
|
|
|
|
|
|
# Override conftest DB fixtures — these tests use mocks, no real DB needed
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
def db_setup():
|
|
"""No-op override of conftest db_setup."""
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_tables(db_setup):
|
|
"""No-op override of conftest clean_tables."""
|
|
yield
|
|
|
|
|
|
# ─── Helpers ───
|
|
|
|
|
|
def _make_listing(
|
|
*,
|
|
name: str = "test_plugin",
|
|
display_name: str = "Test Plugin",
|
|
version: str = "1.0.0",
|
|
download_url: str = "https://example.com/plugin.zip",
|
|
tags: list[str] | None = None,
|
|
is_verified: bool = True,
|
|
signature_public_key: str = "",
|
|
download_count: int = 0,
|
|
) -> MarketplaceListing:
|
|
"""Create a MarketplaceListing instance with defaults."""
|
|
return MarketplaceListing(
|
|
id=uuid.uuid4(),
|
|
name=name,
|
|
display_name=display_name,
|
|
description="A test plugin",
|
|
version=version,
|
|
author="Test Author",
|
|
homepage="https://example.com",
|
|
download_url=download_url,
|
|
signature_public_key=signature_public_key,
|
|
icon="",
|
|
screenshots=[],
|
|
tags=tags or ["productivity"],
|
|
price=0.0,
|
|
is_verified=is_verified,
|
|
download_count=download_count,
|
|
min_app_version="0.0.0",
|
|
license="MIT",
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
|
|
|
|
def _mock_session() -> AsyncMock:
|
|
"""Create a mock AsyncSession."""
|
|
session = AsyncMock()
|
|
session.add = MagicMock()
|
|
session.flush = AsyncMock()
|
|
session.execute = AsyncMock()
|
|
session.commit = AsyncMock()
|
|
session.rollback = AsyncMock()
|
|
return session
|
|
|
|
|
|
def _create_valid_zip() -> bytes:
|
|
"""Create a valid ZIP file in memory."""
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("plugin.py", "# test plugin\n")
|
|
zf.writestr("manifest.json", '{"name": "test_plugin"}')
|
|
return buf.getvalue()
|
|
|
|
|
|
# ─── Service-Layer Tests ───
|
|
|
|
|
|
class TestFetchListings:
|
|
"""Tests for fetch_listings() service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_listings_basic(self):
|
|
"""fetch_listings returns paginated listings."""
|
|
db = _mock_session()
|
|
|
|
listing = _make_listing()
|
|
|
|
# Mock count query
|
|
count_result = MagicMock()
|
|
count_result.scalar.return_value = 1
|
|
|
|
# Mock paginated query
|
|
list_result = MagicMock()
|
|
list_result.scalars.return_value.all.return_value = [listing]
|
|
|
|
db.execute.side_effect = [count_result, list_result]
|
|
|
|
result = await fetch_listings(db, page=1, page_size=20)
|
|
|
|
assert result["total"] == 1
|
|
assert len(result["listings"]) == 1
|
|
assert result["listings"][0]["name"] == "test_plugin"
|
|
assert result["page"] == 1
|
|
assert result["page_size"] == 20
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_listings_with_search(self):
|
|
"""fetch_listings filters by search term."""
|
|
db = _mock_session()
|
|
|
|
listing = _make_listing(name="my_cool_plugin", display_name="My Cool Plugin")
|
|
|
|
count_result = MagicMock()
|
|
count_result.scalar.return_value = 1
|
|
|
|
list_result = MagicMock()
|
|
list_result.scalars.return_value.all.return_value = [listing]
|
|
|
|
db.execute.side_effect = [count_result, list_result]
|
|
|
|
result = await fetch_listings(db, search="cool")
|
|
|
|
assert result["total"] == 1
|
|
assert result["listings"][0]["display_name"] == "My Cool Plugin"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_listings_with_tag_filter(self):
|
|
"""fetch_listings filters by tags."""
|
|
db = _mock_session()
|
|
|
|
listing = _make_listing(tags=["productivity", "automation"])
|
|
|
|
count_result = MagicMock()
|
|
count_result.scalar.return_value = 1
|
|
|
|
list_result = MagicMock()
|
|
list_result.scalars.return_value.all.return_value = [listing]
|
|
|
|
db.execute.side_effect = [count_result, list_result]
|
|
|
|
result = await fetch_listings(db, tags=["productivity"])
|
|
|
|
assert result["total"] == 1
|
|
assert "productivity" in result["listings"][0]["tags"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_listings_empty(self):
|
|
"""fetch_listings returns empty list when no listings exist."""
|
|
db = _mock_session()
|
|
|
|
count_result = MagicMock()
|
|
count_result.scalar.return_value = 0
|
|
|
|
list_result = MagicMock()
|
|
list_result.scalars.return_value.all.return_value = []
|
|
|
|
db.execute.side_effect = [count_result, list_result]
|
|
|
|
result = await fetch_listings(db)
|
|
|
|
assert result["total"] == 0
|
|
assert len(result["listings"]) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_listings_pagination(self):
|
|
"""fetch_listings respects page and page_size."""
|
|
db = _mock_session()
|
|
|
|
count_result = MagicMock()
|
|
count_result.scalar.return_value = 50
|
|
|
|
list_result = MagicMock()
|
|
list_result.scalars.return_value.all.return_value = []
|
|
|
|
db.execute.side_effect = [count_result, list_result]
|
|
|
|
result = await fetch_listings(db, page=3, page_size=10)
|
|
|
|
assert result["page"] == 3
|
|
assert result["page_size"] == 10
|
|
assert result["total"] == 50
|
|
|
|
|
|
class TestGetListingByName:
|
|
"""Tests for get_listing_by_name() service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_listing_by_name_found(self):
|
|
"""get_listing_by_name returns listing when found."""
|
|
db = _mock_session()
|
|
listing = _make_listing(name="my_plugin")
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = listing
|
|
db.execute.return_value = mock_result
|
|
|
|
result = await get_listing_by_name(db, "my_plugin")
|
|
|
|
assert result is not None
|
|
assert result.name == "my_plugin"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_listing_by_name_not_found(self):
|
|
"""get_listing_by_name returns None when not found."""
|
|
db = _mock_session()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
db.execute.return_value = mock_result
|
|
|
|
result = await get_listing_by_name(db, "nonexistent")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestDownloadPlugin:
|
|
"""Tests for download_plugin() service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_plugin_success(self):
|
|
"""download_plugin downloads and returns a valid ZIP path."""
|
|
zip_content = _create_valid_zip()
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.content = zip_content
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
|
zip_path = await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
|
|
|
assert zip_path.exists()
|
|
assert zipfile.is_zipfile(zip_path)
|
|
# Cleanup
|
|
import shutil
|
|
shutil.rmtree(zip_path.parent, ignore_errors=True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_plugin_invalid_zip(self):
|
|
"""download_plugin raises ValueError for invalid ZIP."""
|
|
mock_response = MagicMock()
|
|
mock_response.content = b"not a zip file"
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
|
with pytest.raises(ValueError, match="not a valid ZIP"):
|
|
await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_plugin_size_limit(self):
|
|
"""download_plugin raises ValueError when ZIP exceeds size limit."""
|
|
# Create content larger than MARKETPLACE_MAX_ZIP_SIZE
|
|
from app.plugins.builtins.marketplace.config import MARKETPLACE_MAX_ZIP_SIZE
|
|
|
|
large_content = b"x" * (MARKETPLACE_MAX_ZIP_SIZE + 1)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.content = large_content
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
|
with pytest.raises(ValueError, match="too large"):
|
|
await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_plugin_empty_url(self):
|
|
"""download_plugin raises ValueError for empty download URL."""
|
|
with pytest.raises(ValueError, match="No download URL"):
|
|
await download_plugin("test_plugin", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_plugin_http_error(self):
|
|
"""download_plugin raises ValueError on HTTP error."""
|
|
import httpx
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection failed"))
|
|
|
|
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
|
with pytest.raises(ValueError, match="Failed to download"):
|
|
await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
|
|
|
|
|
class TestVerifyPlugin:
|
|
"""Tests for verify_plugin() service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_missing_signature(self):
|
|
"""verify_plugin returns False when signature is missing."""
|
|
zip_path = Path("/tmp/fake.zip")
|
|
result = await verify_plugin(zip_path, signature=None, public_key=b"some_key")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_missing_public_key(self):
|
|
"""verify_plugin returns False when public_key is missing."""
|
|
zip_path = Path("/tmp/fake.zip")
|
|
result = await verify_plugin(zip_path, signature=b"some_sig", public_key=None)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_both_missing(self):
|
|
"""verify_plugin returns False when both signature and public_key are missing."""
|
|
zip_path = Path("/tmp/fake.zip")
|
|
result = await verify_plugin(zip_path, signature=None, public_key=None)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_valid_signature(self):
|
|
"""verify_plugin returns True when PluginSignature.verify_signature succeeds."""
|
|
zip_path = Path("/tmp/fake.zip")
|
|
|
|
with patch(
|
|
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
|
|
return_value=True,
|
|
) as mock_verify:
|
|
result = await verify_plugin(
|
|
zip_path=zip_path,
|
|
signature=b"valid_sig",
|
|
public_key=b"valid_key",
|
|
)
|
|
|
|
assert result is True
|
|
mock_verify.assert_called_once_with(
|
|
zip_path=zip_path,
|
|
signature=b"valid_sig",
|
|
public_key=b"valid_key",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_invalid_signature(self):
|
|
"""verify_plugin returns False when PluginSignature.verify_signature fails."""
|
|
zip_path = Path("/tmp/fake.zip")
|
|
|
|
with patch(
|
|
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
|
|
return_value=False,
|
|
):
|
|
result = await verify_plugin(
|
|
zip_path=zip_path,
|
|
signature=b"invalid_sig",
|
|
public_key=b"valid_key",
|
|
)
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_exception_returns_false(self):
|
|
"""verify_plugin returns False when verification raises an exception."""
|
|
zip_path = Path("/tmp/fake.zip")
|
|
|
|
with patch(
|
|
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
|
|
side_effect=Exception("Verification error"),
|
|
):
|
|
result = await verify_plugin(
|
|
zip_path=zip_path,
|
|
signature=b"sig",
|
|
public_key=b"key",
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestInstallPlugin:
|
|
"""Tests for install_plugin() service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_plugin_not_found(self):
|
|
"""install_plugin raises ValueError when plugin not in marketplace."""
|
|
db = _mock_session()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
db.execute.return_value = mock_result
|
|
|
|
with pytest.raises(ValueError, match="not found in marketplace"):
|
|
await install_plugin(db, "nonexistent_plugin")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_plugin_success(self):
|
|
"""install_plugin downloads, verifies, and installs a plugin."""
|
|
db = _mock_session()
|
|
listing = _make_listing(name="test_plugin", signature_public_key="")
|
|
|
|
# get_listing_by_name returns listing
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = listing
|
|
db.execute.return_value = mock_result
|
|
|
|
zip_content = _create_valid_zip()
|
|
|
|
# Mock download_plugin
|
|
temp_dir = Path("/tmp/marketplace_test_install")
|
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
zip_path = temp_dir / "test_plugin.zip"
|
|
zip_path.write_bytes(zip_content)
|
|
|
|
# Mock plugin service
|
|
mock_service = MagicMock()
|
|
mock_service.install_plugin_from_zip = AsyncMock(return_value={"success": True})
|
|
mock_service.activate_plugin = AsyncMock()
|
|
|
|
with (
|
|
patch("app.plugins.builtins.marketplace.services.download_plugin", AsyncMock(return_value=zip_path)),
|
|
patch("app.services.plugin_service.get_plugin_service", return_value=mock_service),
|
|
patch("app.plugins.builtins.marketplace.services.PluginSignature.compute_hash", return_value="fake_hash"),
|
|
):
|
|
result = await install_plugin(db, "test_plugin", activate=False)
|
|
|
|
assert result["success"] is True
|
|
assert result["name"] == "test_plugin"
|
|
assert result["installed"] is True
|
|
assert result["activated"] is False
|
|
mock_service.install_plugin_from_zip.assert_awaited_once()
|
|
|
|
# Cleanup
|
|
import shutil
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_plugin_with_activate(self):
|
|
"""install_plugin activates plugin when activate=True."""
|
|
db = _mock_session()
|
|
listing = _make_listing(name="test_plugin")
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = listing
|
|
db.execute.return_value = mock_result
|
|
|
|
zip_content = _create_valid_zip()
|
|
temp_dir = Path("/tmp/marketplace_test_activate")
|
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
zip_path = temp_dir / "test_plugin.zip"
|
|
zip_path.write_bytes(zip_content)
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.install_plugin_from_zip = AsyncMock(return_value={"success": True})
|
|
mock_service.activate_plugin = AsyncMock()
|
|
|
|
with (
|
|
patch("app.plugins.builtins.marketplace.services.download_plugin", AsyncMock(return_value=zip_path)),
|
|
patch("app.services.plugin_service.get_plugin_service", return_value=mock_service),
|
|
patch("app.plugins.builtins.marketplace.services.PluginSignature.compute_hash", return_value="fake_hash"),
|
|
):
|
|
result = await install_plugin(db, "test_plugin", activate=True)
|
|
|
|
assert result["activated"] is True
|
|
mock_service.activate_plugin.assert_awaited_once()
|
|
|
|
import shutil
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_plugin_download_failure(self):
|
|
"""install_plugin raises ValueError when download fails."""
|
|
db = _mock_session()
|
|
listing = _make_listing(name="test_plugin")
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = listing
|
|
db.execute.return_value = mock_result
|
|
|
|
with patch(
|
|
"app.plugins.builtins.marketplace.services.download_plugin",
|
|
AsyncMock(side_effect=ValueError("Download failed")),
|
|
):
|
|
with pytest.raises(ValueError, match="Download failed"):
|
|
await install_plugin(db, "test_plugin")
|
|
|
|
|
|
class TestGetCategories:
|
|
"""Tests for get_categories() service function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_categories_returns_unique_tags(self):
|
|
"""get_categories returns sorted unique tags from all listings."""
|
|
db = _mock_session()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [
|
|
["productivity", "automation"],
|
|
["communication"],
|
|
["productivity", "ai"],
|
|
]
|
|
db.execute.return_value = mock_result
|
|
|
|
result = await get_categories(db)
|
|
|
|
assert result == ["ai", "automation", "communication", "productivity"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_categories_empty(self):
|
|
"""get_categories returns empty list when no listings exist."""
|
|
db = _mock_session()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = []
|
|
db.execute.return_value = mock_result
|
|
|
|
result = await get_categories(db)
|
|
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_categories_with_none_tags(self):
|
|
"""get_categories handles None tag values gracefully."""
|
|
db = _mock_session()
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [
|
|
["productivity"],
|
|
None,
|
|
[],
|
|
]
|
|
db.execute.return_value = mock_result
|
|
|
|
result = await get_categories(db)
|
|
|
|
assert result == ["productivity"]
|
|
|
|
|
|
# ─── Model Tests ───
|
|
|
|
|
|
class TestMarketplaceListingModel:
|
|
"""Tests for the MarketplaceListing model."""
|
|
|
|
def test_model_has_required_fields(self):
|
|
"""MarketplaceListing has name, display_name, version, download_url."""
|
|
listing = MarketplaceListing(
|
|
name="test_plugin",
|
|
display_name="Test Plugin",
|
|
version="1.0.0",
|
|
download_url="https://example.com/plugin.zip",
|
|
)
|
|
assert listing.name == "test_plugin"
|
|
assert listing.display_name == "Test Plugin"
|
|
assert listing.version == "1.0.0"
|
|
assert listing.download_url == "https://example.com/plugin.zip"
|
|
|
|
def test_model_defaults(self):
|
|
"""MarketplaceListing has correct column defaults."""
|
|
cols = MarketplaceListing.__table__.c
|
|
assert cols.price.default.arg == 0.0
|
|
assert cols.is_verified.default.arg is False
|
|
assert cols.download_count.default.arg == 0
|
|
assert cols.license.default.arg == "MIT"
|
|
assert cols.min_app_version.default.arg == "0.0.0"
|
|
|
|
def test_model_table_name(self):
|
|
"""MarketplaceListing uses correct table name."""
|
|
assert MarketplaceListing.__tablename__ == "marketplace_listings"
|
|
|
|
def test_model_is_global_not_tenant_scoped(self):
|
|
"""MarketplaceListing does NOT have tenant_id (global table)."""
|
|
# MarketplaceListing should not inherit TenantMixin
|
|
assert not hasattr(MarketplaceListing, "tenant_id")
|
|
|
|
|
|
# ─── Route-Layer Tests ───
|
|
|
|
|
|
def _create_marketplace_app() -> FastAPI:
|
|
"""Create a minimal FastAPI app with marketplace router and mocked dependencies."""
|
|
app = FastAPI()
|
|
app.include_router(marketplace_router)
|
|
|
|
async def _mock_get_db():
|
|
db = _mock_session()
|
|
yield db
|
|
|
|
async def _mock_get_current_user():
|
|
return {
|
|
"user_id": str(uuid.uuid4()),
|
|
"tenant_id": str(uuid.uuid4()),
|
|
"is_system_admin": True,
|
|
}
|
|
|
|
async def _mock_require_admin():
|
|
return {
|
|
"user_id": str(uuid.uuid4()),
|
|
"tenant_id": str(uuid.uuid4()),
|
|
"is_system_admin": True,
|
|
}
|
|
|
|
from app.deps import get_current_user, require_admin
|
|
from app.core.db import get_db
|
|
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
|
app.dependency_overrides[require_admin] = _mock_require_admin
|
|
|
|
return app
|
|
|
|
|
|
class TestMarketplaceRoutes:
|
|
"""Tests for marketplace API routes."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_listings_route(self):
|
|
"""GET /api/v1/marketplace/listings returns listings."""
|
|
app = _create_marketplace_app()
|
|
db = _mock_session()
|
|
|
|
async def _mock_get_db():
|
|
yield db
|
|
|
|
from app.core.db import get_db
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
|
|
listing = _make_listing()
|
|
count_result = MagicMock()
|
|
count_result.scalar.return_value = 1
|
|
list_result = MagicMock()
|
|
list_result.scalars.return_value.all.return_value = [listing]
|
|
db.execute.side_effect = [count_result, list_result]
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/api/v1/marketplace/listings")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 1
|
|
assert len(data["listings"]) == 1
|
|
assert data["listings"][0]["name"] == "test_plugin"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_listing_by_name_route(self):
|
|
"""GET /api/v1/marketplace/listings/{name} returns listing details."""
|
|
app = _create_marketplace_app()
|
|
db = _mock_session()
|
|
|
|
async def _mock_get_db():
|
|
yield db
|
|
|
|
from app.core.db import get_db
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
|
|
listing = _make_listing(name="my_plugin", display_name="My Plugin")
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = listing
|
|
db.execute.return_value = mock_result
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/api/v1/marketplace/listings/my_plugin")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["name"] == "my_plugin"
|
|
assert data["display_name"] == "My Plugin"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_listing_by_name_not_found_route(self):
|
|
"""GET /api/v1/marketplace/listings/{name} returns 404 when not found."""
|
|
app = _create_marketplace_app()
|
|
db = _mock_session()
|
|
|
|
async def _mock_get_db():
|
|
yield db
|
|
|
|
from app.core.db import get_db
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
db.execute.return_value = mock_result
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/api/v1/marketplace/listings/nonexistent")
|
|
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"]["code"] == "not_found"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_categories_route(self):
|
|
"""GET /api/v1/marketplace/categories returns categories."""
|
|
app = _create_marketplace_app()
|
|
db = _mock_session()
|
|
|
|
async def _mock_get_db():
|
|
yield db
|
|
|
|
from app.core.db import get_db
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [
|
|
["productivity", "ai"],
|
|
["communication"],
|
|
]
|
|
db.execute.return_value = mock_result
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/api/v1/marketplace/categories")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 3
|
|
assert "productivity" in data["categories"]
|
|
assert "ai" in data["categories"]
|
|
assert "communication" in data["categories"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_route_no_signature(self):
|
|
"""POST /api/v1/marketplace/verify/{name} returns signature_valid=False when no signature provided."""
|
|
app = _create_marketplace_app()
|
|
db = _mock_session()
|
|
|
|
async def _mock_get_db():
|
|
yield db
|
|
|
|
from app.core.db import get_db
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
|
|
listing = _make_listing(name="test_plugin", signature_public_key="")
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = listing
|
|
db.execute.return_value = mock_result
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/marketplace/verify/test_plugin",
|
|
json={"name": "test_plugin"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["signature_valid"] is False
|
|
assert data["name"] == "test_plugin"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_plugin_route_not_found(self):
|
|
"""POST /api/v1/marketplace/verify/{name} returns 404 when plugin not found."""
|
|
app = _create_marketplace_app()
|
|
db = _mock_session()
|
|
|
|
async def _mock_get_db():
|
|
yield db
|
|
|
|
from app.core.db import get_db
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
db.execute.return_value = mock_result
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/marketplace/verify/nonexistent",
|
|
json={"name": "nonexistent"},
|
|
)
|
|
|
|
assert resp.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_plugin_route_not_found(self):
|
|
"""POST /api/v1/marketplace/install/{name} returns 400 when plugin not found."""
|
|
app = _create_marketplace_app()
|
|
db = _mock_session()
|
|
|
|
async def _mock_get_db():
|
|
yield db
|
|
|
|
from app.core.db import get_db
|
|
app.dependency_overrides[get_db] = _mock_get_db
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
db.execute.return_value = mock_result
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/marketplace/install/nonexistent",
|
|
json={"name": "nonexistent"},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["detail"]["code"] == "install_error"
|