"""Tests for GitHubRegistry — GitHub topic search + DB cache.""" import json import os from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch import pytest from app import create_app from extensions import db as sa_db @pytest.fixture() def app(tmp_path): """Flask app with isolated SQLite DB.""" db_path = str(tmp_path / "test.db") os.environ["SUBLARR_DB_PATH"] = db_path os.environ["SUBLARR_API_KEY"] = "" os.environ["SUBLARR_LOG_LEVEL"] = "ERROR" from config import reload_settings reload_settings() application = create_app(testing=True) application.config["TESTING"] = True with application.app_context(): sa_db.create_all() yield application os.environ.pop("SUBLARR_DB_PATH", None) os.environ.pop("SUBLARR_API_KEY", None) os.environ.pop("SUBLARR_LOG_LEVEL", None) def _make_registry(github_token: str = "") -> "GitHubRegistry": # noqa: F821 from services.github_registry import GitHubRegistry return GitHubRegistry(github_token=github_token) def _fresh_timestamp() -> datetime: return datetime.now(UTC) def _stale_timestamp() -> datetime: return datetime.now(UTC) - timedelta(hours=2) def _insert_cache_entry(app_ctx, name: str, last_fetched: datetime) -> None: from db.models.plugins import MarketplaceCache with app_ctx.app_context(): entry = MarketplaceCache( name=name, display_name="Test Plugin", author="testauthor", version="1.0.0", description="A test plugin", github_url="https://github.com/testauthor/test-plugin", zip_url="https://github.com/testauthor/test-plugin/archive/v1.0.0.zip", sha256="abc123", capabilities=json.dumps(["provider"]), min_sublarr_version="0.22.0", is_official=0, last_fetched=last_fetched, ) sa_db.session.add(entry) sa_db.session.commit() # --------------------------------------------------------------------------- # Test 1: cache hit — GitHub API NOT called when cache is fresh # --------------------------------------------------------------------------- def test_search_uses_cache_when_fresh(app): with app.app_context(): _insert_cache_entry(app, "cached-plugin", _fresh_timestamp()) registry = _make_registry() with patch.object(registry.session, "get") as mock_get: results = registry.search(force_refresh=False) mock_get.assert_not_called() assert len(results) == 1 assert results[0]["name"] == "cached-plugin" # --------------------------------------------------------------------------- # Test 2: cache miss — GitHub API fetched, results stored in DB # --------------------------------------------------------------------------- def _make_search_response(repos: list[dict]) -> MagicMock: resp = MagicMock() resp.raise_for_status.return_value = None resp.json.return_value = {"items": repos} return resp def _make_manifest_response(manifest: dict) -> MagicMock: resp = MagicMock() resp.raise_for_status.return_value = None resp.json.return_value = manifest return resp def test_search_fetches_github_when_stale(app): repo = { "full_name": "testauthor/my-provider", "html_url": "https://github.com/testauthor/my-provider", "description": "A great provider", "owner": {"login": "testauthor"}, } manifest = { "name": "my-provider", "display_name": "My Provider", "version": "1.2.0", "entry_point": "provider.py", "class_name": "MyProvider", "author": "testauthor", "capabilities": ["provider"], "min_sublarr_version": "0.22.0", "zip_url": "https://example.com/my-provider.zip", "sha256": "deadbeef", } search_resp = _make_search_response([repo]) manifest_resp = _make_manifest_response(manifest) with app.app_context(): registry = _make_registry() # Map side_effect: first call = GitHub search, second = manifest fetch registry.session.get = MagicMock(side_effect=[search_resp, manifest_resp]) with patch.object(registry, "_load_official_names", return_value=set()): results = registry.search(force_refresh=True) assert len(results) == 1 assert results[0]["name"] == "my-provider" assert results[0]["version"] == "1.2.0" # Verify stored in DB from db.models.plugins import MarketplaceCache row = sa_db.session.get(MarketplaceCache, "my-provider") assert row is not None assert row.version == "1.2.0" # --------------------------------------------------------------------------- # Test 3: sha256 flows from manifest into result dict # --------------------------------------------------------------------------- def test_sha256_included_in_result(app): repo = { "full_name": "author/sha-plugin", "html_url": "https://github.com/author/sha-plugin", "description": "", "owner": {"login": "author"}, } manifest = { "name": "sha-plugin", "display_name": "SHA Plugin", "version": "0.1.0", "entry_point": "provider.py", "class_name": "ShaPlugin", "sha256": "cafebabe1234567890abcdef", } search_resp = _make_search_response([repo]) manifest_resp = _make_manifest_response(manifest) with app.app_context(): registry = _make_registry() registry.session.get = MagicMock(side_effect=[search_resp, manifest_resp]) with patch.object(registry, "_load_official_names", return_value=set()): results = registry.search(force_refresh=True) assert len(results) == 1 assert results[0]["sha256"] == "cafebabe1234567890abcdef" # --------------------------------------------------------------------------- # Test 4: manifest missing required field — plugin skipped # --------------------------------------------------------------------------- def test_missing_required_field_skipped(app): repo = { "full_name": "author/bad-plugin", "html_url": "https://github.com/author/bad-plugin", "description": "", "owner": {"login": "author"}, } # Missing 'class_name' manifest = { "name": "bad-plugin", "display_name": "Bad Plugin", "version": "0.1.0", "entry_point": "provider.py", # class_name intentionally absent } search_resp = _make_search_response([repo]) manifest_resp = _make_manifest_response(manifest) with app.app_context(): registry = _make_registry() registry.session.get = MagicMock(side_effect=[search_resp, manifest_resp]) with patch.object(registry, "_load_official_names", return_value=set()): results = registry.search(force_refresh=True) assert results == [] # --------------------------------------------------------------------------- # Test 5: official badge — is_official=True for names in official registry # --------------------------------------------------------------------------- def test_official_badge(app): repo = { "full_name": "community/opensubtitles-enhanced", "html_url": "https://github.com/community/opensubtitles-enhanced", "description": "Enhanced OpenSubtitles provider", "owner": {"login": "community"}, } manifest = { "name": "opensubtitles-enhanced", "display_name": "OpenSubtitles Enhanced", "version": "2.0.0", "entry_point": "provider.py", "class_name": "OpenSubtitlesEnhancedProvider", } search_resp = _make_search_response([repo]) manifest_resp = _make_manifest_response(manifest) with app.app_context(): registry = _make_registry() registry.session.get = MagicMock(side_effect=[search_resp, manifest_resp]) with patch.object( registry, "_load_official_names", return_value={"opensubtitles-enhanced"}, ): results = registry.search(force_refresh=True) assert len(results) == 1 assert results[0]["is_official"] is True # --------------------------------------------------------------------------- # A second repo cannot take over a plugin name (security review of #71) # --------------------------------------------------------------------------- def test_another_repo_cannot_take_over_a_cached_name(app): _insert_cache_entry(app, "test-plugin", _stale_timestamp()) repo = { "full_name": "mallory/test-plugin", "html_url": "https://github.com/mallory/test-plugin", "description": "", "owner": {"login": "mallory"}, } manifest = { "name": "test-plugin", "display_name": "Test Plugin", "version": "9.9.9", "entry_point": "provider.py", "class_name": "Evil", "zip_url": "https://github.com/mallory/test-plugin/archive/evil.zip", "sha256": "f" * 64, } with app.app_context(): registry = _make_registry() registry.session.get = MagicMock( side_effect=[_make_search_response([repo]), _make_manifest_response(manifest)] ) with patch.object(registry, "_load_official_names", return_value=set()): results = registry.search(force_refresh=True) from db.models.plugins import MarketplaceCache row = sa_db.session.get(MarketplaceCache, "test-plugin") assert row.github_url == "https://github.com/testauthor/test-plugin" assert row.sha256 == "abc123" assert all(r["github_url"] != repo["html_url"] for r in results)