diff --git a/backend/secuscan/cache.py b/backend/secuscan/cache.py index a42c3edbf..177b49675 100644 --- a/backend/secuscan/cache.py +++ b/backend/secuscan/cache.py @@ -128,3 +128,23 @@ async def get_cache() -> CacheClient: if cache is None: raise RuntimeError("Cache not initialized") return cache + + +async def invalidate_view_cache(): + """Clear aggregate caches after writes.""" + try: + c = await get_cache() + except RuntimeError: + return + for prefix in ["summary:", "findings:", "reports:", "tasks:"]: + await c.delete_prefix(prefix) + + +async def invalidate_plugin_caches(): + """Clear plugin and dashboard summary caches when plugin state changes.""" + try: + c = await get_cache() + except RuntimeError: + return + for prefix in ["summary:", "plugins:"]: + await c.delete_prefix(prefix) diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index 623c3e4d1..770ec23d6 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -141,6 +141,14 @@ async def load_plugins(self) -> int: logger.error(f"Failed to load plugin from {plugin_dir}: {e}") logger.info(f"Loaded {loaded} plugins") + + # Invalidate caches when plugin state changes + try: + from .cache import invalidate_plugin_caches + await invalidate_plugin_caches() + except Exception as e: + logger.warning(f"Failed to invalidate plugin caches: {e}") + return loaded async def _load_plugin_metadata(self, metadata_file: Path) -> PluginMetadata: diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 44d1103d6..93b708a36 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -94,7 +94,7 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: logger = logging.getLogger(__name__) -from .cache import get_cache +from .cache import get_cache, invalidate_view_cache from .models import ( TaskCreateRequest, TaskResponse, TaskResult, PluginListResponse, ErrorResponse, BulkDeleteRequest, @@ -204,11 +204,7 @@ async def get_or_set_cached(key: str, builder): await cache.set_json(key, value) return value -async def invalidate_view_cache(): - """Clear aggregate caches after writes.""" - cache = await get_cache() - for prefix in ["summary:", "findings:", "reports:", "tasks:"]: - await cache.delete_prefix(prefix) + async def require_owned_task(db, task_id: str, owner: str, columns: str = "owner_id") -> Dict[str, Any]: diff --git a/testing/backend/test_cache_invalidation.py b/testing/backend/test_cache_invalidation.py index dd1759466..96ef873b4 100644 --- a/testing/backend/test_cache_invalidation.py +++ b/testing/backend/test_cache_invalidation.py @@ -1,32 +1,52 @@ """ -Cache invalidation tests - Simple version that WILL pass +Cache invalidation tests """ import pytest from unittest.mock import AsyncMock, patch - class TestInvalidateViewCache: - """Test the cache invalidation helper function""" + """Test the cache invalidation helper functions""" @pytest.mark.asyncio async def test_invalidate_view_cache_clears_prefixes(self): """Test that invalidate_view_cache clears all required prefixes""" - from backend.secuscan.routes import invalidate_view_cache + from backend.secuscan.cache import invalidate_view_cache mock_cache = AsyncMock() - - with patch("backend.secuscan.routes.get_cache", return_value=mock_cache): + with patch("backend.secuscan.cache.get_cache", return_value=mock_cache): await invalidate_view_cache() expected_prefixes = ["summary:", "findings:", "reports:", "tasks:"] - for prefix in expected_prefixes: mock_cache.delete_prefix.assert_any_call(prefix) + assert mock_cache.delete_prefix.call_count == len(expected_prefixes) + + @pytest.mark.asyncio + async def test_invalidate_plugin_caches_clears_prefixes(self): + """Test that invalidate_plugin_caches clears plugin and dashboard prefixes""" + from backend.secuscan.cache import invalidate_plugin_caches + mock_cache = AsyncMock() + with patch("backend.secuscan.cache.get_cache", return_value=mock_cache): + await invalidate_plugin_caches() + + expected_prefixes = ["summary:", "plugins:"] + for prefix in expected_prefixes: + mock_cache.delete_prefix.assert_any_call(prefix) assert mock_cache.delete_prefix.call_count == len(expected_prefixes) def test_function_exists(self): - """Test that invalidate_view_cache function exists""" + """Test that invalidate_view_cache function exists in routes (backwards compatibility)""" from backend.secuscan.routes import invalidate_view_cache assert callable(invalidate_view_cache) + + @pytest.mark.asyncio + async def test_load_plugins_invalidates_cache(self, tmp_path): + """Test that loading plugins automatically invalidates the plugin cache.""" + from backend.secuscan.plugins import PluginManager + + manager = PluginManager(str(tmp_path)) + with patch("backend.secuscan.cache.invalidate_plugin_caches") as mock_invalidate: + await manager.load_plugins() + mock_invalidate.assert_awaited_once()