Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/secuscan/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 8 additions & 0 deletions backend/secuscan/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 2 additions & 6 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down
36 changes: 28 additions & 8 deletions testing/backend/test_cache_invalidation.py
Original file line number Diff line number Diff line change
@@ -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()
Loading