# #region Test.RbacPermissionCatalog [C:3] [TYPE Module] [SEMANTICS test,rbac,permission,discovery,route] # @BRIEF Tests for services/rbac_permission_catalog.py — permission discovery, route scanning, sync. # @RELATION BINDS_TO -> [rbac_permission_catalog] from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import os import tempfile from unittest.mock import MagicMock, patch, PropertyMock import pytest @pytest.fixture(autouse=True) def _clear_lru_caches(): """Clear lru_cache between tests to prevent isolation failures.""" from src.services.rbac_permission_catalog import ( _discover_plugin_execute_permissions_cached, _discover_route_permissions_cached, ) _discover_route_permissions_cached.cache_clear() _discover_plugin_execute_permissions_cached.cache_clear() class TestIterRouteFiles: """_iter_route_files — discover Python files in routes directory.""" def test_directory_not_found(self): from src.services.rbac_permission_catalog import _iter_route_files with patch("src.services.rbac_permission_catalog.ROUTES_DIR", Path("/nonexistent/path")): result = _iter_route_files() assert result == [] def test_finds_py_files(self): from src.services.rbac_permission_catalog import _iter_route_files with tempfile.TemporaryDirectory() as tmpdir: routes_dir = Path(tmpdir) / "routes" routes_dir.mkdir() (routes_dir / "test_route.py").write_text("") (routes_dir / "__init__.py").write_text("") (routes_dir / "subdir").mkdir() (routes_dir / "subdir" / "nested.py").write_text("") # These should be excluded (routes_dir / "__tests__").mkdir() (routes_dir / "__tests__" / "test_excluded.py").write_text("") (routes_dir / "__pycache__").mkdir() (routes_dir / "__pycache__" / "cached.py").write_text("") with patch("src.services.rbac_permission_catalog.ROUTES_DIR", routes_dir): files = _iter_route_files() filenames = [f.name for f in files] assert "test_route.py" in filenames assert "__init__.py" in filenames assert "nested.py" in filenames assert "test_excluded.py" not in filenames assert "cached.py" not in filenames class TestDiscoverRoutePermissions: """_discover_route_permissions — extract has_permission() calls from route files.""" def test_extracts_permissions(self): from src.services.rbac_permission_catalog import _discover_route_permissions with tempfile.TemporaryDirectory() as tmpdir: route_file = Path(tmpdir) / "test_route.py" route_file.write_text(""" has_permission("dashboards", "READ") has_permission("datasets", "WRITE") # Not a permission call: x = has_permission("users", "ADMIN") """) with patch("src.services.rbac_permission_catalog.ROUTES_DIR", Path(tmpdir)): result = _discover_route_permissions() assert ("dashboards", "READ") in result assert ("datasets", "WRITE") in result assert ("users", "ADMIN") in result def test_empty_file(self): from src.services.rbac_permission_catalog import _discover_route_permissions with tempfile.TemporaryDirectory() as tmpdir: route_file = Path(tmpdir) / "empty.py" route_file.write_text("# No permissions here") with patch("src.services.rbac_permission_catalog.ROUTES_DIR", Path(tmpdir)): result = _discover_route_permissions() assert result == set() def test_multiple_files(self): from src.services.rbac_permission_catalog import _discover_route_permissions with tempfile.TemporaryDirectory() as tmpdir: f1 = Path(tmpdir) / "routes_a.py" f1.write_text('has_permission("dashboard", "READ")') f2 = Path(tmpdir) / "routes_b.py" f2.write_text('has_permission("dataset", "WRITE")') with patch("src.services.rbac_permission_catalog.ROUTES_DIR", Path(tmpdir)): result = _discover_route_permissions() assert ("dashboard", "READ") in result assert ("dataset", "WRITE") in result def test_single_quote_variants(self): from src.services.rbac_permission_catalog import _discover_route_permissions with tempfile.TemporaryDirectory() as tmpdir: f = Path(tmpdir) / "test.py" f.write_text("""has_permission('dashboard', 'READ')""") with patch("src.services.rbac_permission_catalog.ROUTES_DIR", Path(tmpdir)): result = _discover_route_permissions() assert ("dashboard", "READ") in result class TestDiscoverDeclaredPermissions: """discover_declared_permissions — cached public API.""" def test_returns_permissions(self): from src.services.rbac_permission_catalog import discover_declared_permissions with patch("src.services.rbac_permission_catalog._discover_route_permissions", return_value={("dashboard", "READ"), ("dataset", "WRITE")}): result = discover_declared_permissions() assert ("dashboard", "READ") in result assert ("dataset", "WRITE") in result def test_caching(self): from src.services.rbac_permission_catalog import discover_declared_permissions mock_fn = MagicMock(return_value={("dashboard", "READ")}) with patch("src.services.rbac_permission_catalog._discover_route_permissions", mock_fn): result1 = discover_declared_permissions() result2 = discover_declared_permissions() assert result1 == result2 # With no plugin_loader, it calls _discover_route_permissions # The lru_cache should only call once if args match # discover_declared_permissions takes optional plugin_loader def test_with_plugin_loader(self): from src.services.rbac_permission_catalog import discover_declared_permissions mock_loader = MagicMock() mock_loader.list_plugins.return_value = [] mock_fn = MagicMock(return_value={("dashboard", "READ")}) with patch("src.services.rbac_permission_catalog._discover_route_permissions", mock_fn): result = discover_declared_permissions(plugin_loader=mock_loader) # Should call plugin loader to discover plugin permissions assert ("dashboard", "READ") in result class TestSyncPermissionCatalog: """sync_permission_catalog — sync discovered permissions to DB.""" def test_creates_new_permissions(self): from src.services.rbac_permission_catalog import sync_permission_catalog db = MagicMock() db.query.return_value.all.return_value = [] # No existing permissions discovered = {("dashboard", "READ"), ("dataset", "WRITE")} result = sync_permission_catalog(db, discovered) assert result == 2 # 2 new permissions assert db.add.call_count >= 2 db.commit.assert_called_once() def test_skips_existing_permissions(self): from src.services.rbac_permission_catalog import sync_permission_catalog existing = MagicMock() existing.resource = "dashboard" existing.action = "READ" db = MagicMock() db.query.return_value.all.return_value = [existing] discovered = {("dashboard", "READ"), ("dataset", "WRITE")} result = sync_permission_catalog(db, discovered) assert result == 1 # 1 new (dataset/WRITE), 1 skipped db.commit.assert_called_once() def test_empty_discovered(self): from src.services.rbac_permission_catalog import sync_permission_catalog db = MagicMock() result = sync_permission_catalog(db, set()) assert result == 0 db.commit.assert_not_called() class TestDiscoverRoutePermissionsErrors: """_discover_route_permissions — error handling.""" def test_oserror_on_read_continues(self): """Lines 60-65: OSError when reading route file is caught and logged.""" from src.services.rbac_permission_catalog import _discover_route_permissions import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmpdir: route_dir = Path(tmpdir) route_file = route_dir / "broken.py" route_file.write_text("has_permission('dashboard', 'READ')") # Make it unreadable route_file.chmod(0o000) with patch("src.services.rbac_permission_catalog.ROUTES_DIR", route_dir): result = _discover_route_permissions() # May get 0 or 1 depending on whether the file is actually unreadable assert isinstance(result, set) route_file.chmod(0o644) class TestDiscoverPluginExecutePermissions: """_discover_plugin_execute_permissions — dynamic plugin permissions.""" def test_plugin_loader_none_returns_empty(self): """Line 94-95: None plugin_loader returns empty set.""" from src.services.rbac_permission_catalog import _discover_plugin_execute_permissions result = _discover_plugin_execute_permissions(plugin_loader=None) assert result == set() def test_plugin_loader_exception_returns_empty(self): """Lines 99-104: exception from get_all_plugin_configs returns empty.""" from src.services.rbac_permission_catalog import _discover_plugin_execute_permissions loader = MagicMock() loader.get_all_plugin_configs.side_effect = RuntimeError("loader failed") result = _discover_plugin_execute_permissions(plugin_loader=loader) assert result == set() def test_plugin_loader_with_configs(self): """Lines 106-110: plugin configs produce EXECUTE permissions.""" from src.services.rbac_permission_catalog import _discover_plugin_execute_permissions config1 = MagicMock() config1.id = "plugin-a" config2 = MagicMock() config2.id = "plugin-b" loader = MagicMock() loader.get_all_plugin_configs.return_value = [config1, config2] result = _discover_plugin_execute_permissions(plugin_loader=loader) assert ("plugin:plugin-a", "EXECUTE") in result assert ("plugin:plugin-b", "EXECUTE") in result assert len(result) == 2 def test_plugin_loader_empty_id_skipped(self): """Line 108: empty plugin id is skipped.""" from src.services.rbac_permission_catalog import _discover_plugin_execute_permissions config = MagicMock() config.id = "" loader = MagicMock() loader.get_all_plugin_configs.return_value = [config] result = _discover_plugin_execute_permissions(plugin_loader=loader) assert result == set() class TestDiscoverPluginExecutePermissionsCached: """_discover_plugin_execute_permissions_cached — cached version.""" def test_cached_returns_tuples(self): from src.services.rbac_permission_catalog import _discover_plugin_execute_permissions_cached result = _discover_plugin_execute_permissions_cached(("p1", "p2")) assert ("plugin:p1", "EXECUTE") in result assert ("plugin:p2", "EXECUTE") in result def test_cached_with_empty_ids(self): from src.services.rbac_permission_catalog import _discover_plugin_execute_permissions_cached result = _discover_plugin_execute_permissions_cached(()) assert result == () # #endregion Test.RbacPermissionCatalog