""" Extended tests for the module whitelist security system. Tests the whitelist constants, validation functions, error hierarchy, and security-critical edge cases in local_deep_research.security.module_whitelist. """ import pytest from unittest.mock import MagicMock, patch from local_deep_research.security.module_whitelist import ( ALLOWED_MODULE_PATHS, ALLOWED_CLASS_NAMES, ALLOWED_MODULES, SecurityError, ModuleNotAllowedError, validate_module_import, get_safe_module_class, ) # --------------------------------------------------------------------------- # ALLOWED_MODULE_PATHS # --------------------------------------------------------------------------- class TestAllowedModulePathsExtended: """Extended tests for the ALLOWED_MODULE_PATHS constant.""" def test_is_frozenset(self): """ALLOWED_MODULE_PATHS must be a frozenset (immutable).""" assert isinstance(ALLOWED_MODULE_PATHS, frozenset) def test_contains_brave_engine(self): """Whitelist contains the Brave search engine module.""" assert ".engines.search_engine_brave" in ALLOWED_MODULE_PATHS def test_contains_arxiv_engine(self): """Whitelist contains the ArXiv search engine module.""" assert ".engines.search_engine_arxiv" in ALLOWED_MODULE_PATHS def test_all_entries_start_with_dot(self): """Every entry must start with '.' enforcing relative-import-only policy.""" for path in ALLOWED_MODULE_PATHS: assert path.startswith("."), ( f"Path {path!r} violates the relative-import-only policy" ) def test_does_not_contain_os(self): """Whitelist must never contain the 'os' module.""" assert "os" not in ALLOWED_MODULE_PATHS def test_does_not_contain_subprocess(self): """Whitelist must never contain the 'subprocess' module.""" assert "subprocess" not in ALLOWED_MODULE_PATHS def test_does_not_contain_sys(self): """Whitelist must never contain the 'sys' module.""" assert "sys" not in ALLOWED_MODULE_PATHS def test_no_dangerous_stdlib_paths(self): """Whitelist must not contain any well-known dangerous stdlib modules.""" dangerous = { "os", "sys", "subprocess", "shutil", "ctypes", "socket", "pickle", "shelve", "code", "codeop", "compileall", "importlib", "builtins", "io", "signal", } for module in dangerous: assert module not in ALLOWED_MODULE_PATHS, ( f"Dangerous module {module!r} must not appear in whitelist" ) def test_immutability(self): """frozenset must refuse mutation attempts.""" with pytest.raises(AttributeError): ALLOWED_MODULE_PATHS.add("os") # type: ignore[attr-defined] with pytest.raises(AttributeError): ALLOWED_MODULE_PATHS.discard( # type: ignore[attr-defined] ".engines.search_engine_brave" ) # --------------------------------------------------------------------------- # ALLOWED_CLASS_NAMES # --------------------------------------------------------------------------- class TestAllowedClassNamesExtended: """Extended tests for the ALLOWED_CLASS_NAMES constant.""" def test_is_frozenset(self): """ALLOWED_CLASS_NAMES must be a frozenset (immutable).""" assert isinstance(ALLOWED_CLASS_NAMES, frozenset) def test_contains_brave_search_engine(self): """Whitelist contains the BraveSearchEngine class.""" assert "BraveSearchEngine" in ALLOWED_CLASS_NAMES def test_contains_arxiv_search_engine(self): """Whitelist contains the ArXivSearchEngine class.""" assert "ArXivSearchEngine" in ALLOWED_CLASS_NAMES def test_does_not_contain_system(self): """Whitelist must not contain 'system' (os.system target).""" assert "system" not in ALLOWED_CLASS_NAMES def test_does_not_contain_exec(self): """Whitelist must not contain 'exec' (code execution target).""" assert "exec" not in ALLOWED_CLASS_NAMES def test_does_not_contain_eval(self): """Whitelist must not contain 'eval' (code execution target).""" assert "eval" not in ALLOWED_CLASS_NAMES def test_no_dangerous_class_names(self): """Whitelist must not contain any well-known dangerous class/function names.""" dangerous = { "system", "exec", "eval", "Popen", "call", "run", "loads", "load", "CDLL", "interact", "__import__", "import_module", "compile", } for name in dangerous: assert name not in ALLOWED_CLASS_NAMES, ( f"Dangerous name {name!r} must not appear in class whitelist" ) def test_immutability(self): """frozenset must refuse mutation attempts.""" with pytest.raises(AttributeError): ALLOWED_CLASS_NAMES.add("system") # type: ignore[attr-defined] # --------------------------------------------------------------------------- # ALLOWED_MODULES (legacy alias) # --------------------------------------------------------------------------- class TestAllowedModulesAlias: """Tests for the ALLOWED_MODULES legacy alias.""" def test_is_same_object_as_allowed_module_paths(self): """ALLOWED_MODULES must be the exact same object as ALLOWED_MODULE_PATHS.""" assert ALLOWED_MODULES is ALLOWED_MODULE_PATHS # --------------------------------------------------------------------------- # SecurityError and ModuleNotAllowedError # --------------------------------------------------------------------------- class TestSecurityErrorHierarchy: """Tests for the SecurityError and ModuleNotAllowedError exception classes.""" def test_security_error_is_exception_subclass(self): """SecurityError must be a subclass of Exception.""" assert issubclass(SecurityError, Exception) def test_module_not_allowed_error_is_security_error(self): """ModuleNotAllowedError must be the same class as SecurityError (legacy alias).""" assert ModuleNotAllowedError is SecurityError def test_security_error_is_catchable_as_exception(self): """SecurityError instances must be catchable as plain Exception.""" with pytest.raises(Exception): raise SecurityError("test") def test_module_not_allowed_error_is_catchable_as_security_error(self): """ModuleNotAllowedError instances must be catchable as SecurityError.""" with pytest.raises(SecurityError): raise ModuleNotAllowedError("test") def test_security_error_preserves_message(self): """SecurityError must preserve its error message.""" err = SecurityError("blocked import attempt") assert str(err) == "blocked import attempt" # --------------------------------------------------------------------------- # validate_module_import # --------------------------------------------------------------------------- class TestValidateModuleImportExtended: """Extended tests for the validate_module_import function.""" # --- happy-path --- def test_valid_module_and_valid_class_returns_true(self): """Returns True when both module and class are whitelisted.""" assert ( validate_module_import( ".engines.search_engine_brave", "BraveSearchEngine" ) is True ) # --- single-invalid dimension --- def test_invalid_module_valid_class_returns_false(self): """Returns False when only the module is not whitelisted.""" assert ( validate_module_import( ".engines.nonexistent_engine", "BraveSearchEngine" ) is False ) def test_valid_module_invalid_class_returns_false(self): """Returns False when only the class is not whitelisted.""" assert ( validate_module_import(".engines.search_engine_brave", "EvilClass") is False ) # --- both-invalid dimension --- def test_both_invalid_returns_false(self): """Returns False when neither module nor class is whitelisted.""" assert validate_module_import(".evil.module", "EvilClass") is False # --- empty / None inputs --- def test_empty_module_path_returns_false(self): """Returns False for an empty string module_path.""" assert validate_module_import("", "BraveSearchEngine") is False def test_empty_class_name_returns_false(self): """Returns False for an empty string class_name.""" assert ( validate_module_import(".engines.search_engine_brave", "") is False ) def test_none_module_path_returns_false(self): """Returns False when module_path is None.""" assert validate_module_import(None, "BraveSearchEngine") is False def test_none_class_name_returns_false(self): """Returns False when class_name is None.""" assert ( validate_module_import(".engines.search_engine_brave", None) is False ) def test_both_none_returns_false(self): """Returns False when both arguments are None.""" assert validate_module_import(None, None) is False # --- CRITICAL SECURITY: non-relative paths --- def test_non_relative_os_returns_false(self): """CRITICAL: bare 'os' module must be rejected as non-relative path.""" assert validate_module_import("os", "system") is False def test_absolute_fully_qualified_path_returns_false(self): """Absolute fully-qualified module path must be rejected.""" assert ( validate_module_import( "local_deep_research.web_search_engines.engines.search_engine_brave", "BraveSearchEngine", ) is False ) def test_double_dot_prefix_returns_false(self): """Path starting with '..' must be rejected (not in whitelist).""" assert validate_module_import("..os", "system") is False def test_triple_dot_prefix_returns_false(self): """Path starting with '...' must be rejected.""" assert validate_module_import("...os", "system") is False def test_non_relative_subprocess_returns_false(self): """Bare 'subprocess' module must be rejected.""" assert validate_module_import("subprocess", "Popen") is False def test_non_relative_sys_returns_false(self): """Bare 'sys' module must be rejected.""" assert validate_module_import("sys", "exit") is False # --- path traversal attempts --- def test_path_traversal_slash(self): """Path traversal using forward slashes must be rejected.""" assert validate_module_import(".engines/../../../os", "system") is False def test_path_traversal_backslash(self): """Path traversal using backslashes must be rejected.""" assert ( validate_module_import(".engines\\..\\..\\subprocess", "Popen") is False ) # --- case sensitivity --- def test_case_sensitive_module_path(self): """Module paths are case-sensitive -- upper-cased variant must be rejected.""" assert ( validate_module_import( ".ENGINES.SEARCH_ENGINE_BRAVE", "BraveSearchEngine" ) is False ) def test_case_sensitive_class_name(self): """Class names are case-sensitive -- lower-cased variant must be rejected.""" assert ( validate_module_import( ".engines.search_engine_brave", "bravesearchengine" ) is False ) # --- whitespace / special chars --- def test_whitespace_module_path_returns_false(self): """A whitespace-only module_path must be rejected (not starting with '.').""" assert validate_module_import(" ", "BraveSearchEngine") is False def test_module_path_with_trailing_space_returns_false(self): """Module path with trailing space must not match whitelist entry.""" assert ( validate_module_import( ".engines.search_engine_brave ", "BraveSearchEngine" ) is False ) def test_class_name_with_trailing_space_returns_false(self): """Class name with trailing space must not match whitelist entry.""" assert ( validate_module_import( ".engines.search_engine_brave", "BraveSearchEngine " ) is False ) # --- injection-style inputs --- def test_semicolon_injection_in_module_path(self): """Semicolon-based injection attempt in module path must be rejected.""" assert validate_module_import(".engines; import os;", "system") is False def test_newline_injection_in_module_path(self): """Newline injection attempt in module path must be rejected.""" assert validate_module_import(".engines\nimport os", "system") is False # --------------------------------------------------------------------------- # get_safe_module_class # --------------------------------------------------------------------------- class TestGetSafeModuleClassExtended: """Extended tests for the get_safe_module_class function.""" def test_raises_security_error_for_non_whitelisted_module(self): """Raises SecurityError when the module path is not whitelisted.""" with pytest.raises(SecurityError): get_safe_module_class(".evil.module", "BraveSearchEngine") def test_raises_security_error_for_non_whitelisted_class(self): """Raises SecurityError when the class name is not whitelisted.""" with pytest.raises(SecurityError): get_safe_module_class( ".engines.search_engine_brave", "MaliciousClass" ) def test_raises_security_error_for_absolute_os(self): """Raises SecurityError for absolute path 'os'.""" with pytest.raises(SecurityError): get_safe_module_class("os", "system") def test_raises_security_error_for_subprocess(self): """Raises SecurityError for absolute path 'subprocess'.""" with pytest.raises(SecurityError): get_safe_module_class("subprocess", "Popen") def test_error_message_contains_module_path(self): """The SecurityError message must include the rejected module path.""" with pytest.raises(SecurityError, match=r"\.evil\.module"): get_safe_module_class(".evil.module", "EvilClass") def test_error_message_contains_class_name(self): """The SecurityError message must include the rejected class name.""" with pytest.raises(SecurityError, match="EvilClass"): get_safe_module_class(".evil.module", "EvilClass") def test_error_message_mentions_whitelist(self): """The SecurityError message must mention 'whitelist' for clarity.""" with pytest.raises(SecurityError, match="whitelist"): get_safe_module_class("os", "system") # --- default package for relative imports --- def test_default_package_used_for_relative_import(self): """When no package kwarg is given, the default package is used for relative imports.""" with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): with patch( "local_deep_research.security.module_whitelist.importlib.import_module", ) as mock_import: mock_module = MagicMock() mock_module.FakeEngine = type("FakeEngine", (), {}) mock_import.return_value = mock_module get_safe_module_class(".engines.fake", "FakeEngine") mock_import.assert_called_once_with( ".engines.fake", package="local_deep_research.web_search_engines", ) # --- custom package parameter --- def test_custom_package_parameter_is_used(self): """When a custom package kwarg is given, it overrides the default.""" with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): with patch( "local_deep_research.security.module_whitelist.importlib.import_module", ) as mock_import: mock_module = MagicMock() mock_module.FakeEngine = type("FakeEngine", (), {}) mock_import.return_value = mock_module get_safe_module_class( ".engines.fake", "FakeEngine", package="my.custom.package", ) mock_import.assert_called_once_with( ".engines.fake", package="my.custom.package", ) # --- downstream exception propagation --- def test_module_not_found_error_propagates(self): """ModuleNotFoundError propagates when a whitelisted module cannot be found.""" with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): with patch( "local_deep_research.security.module_whitelist.importlib.import_module", side_effect=ModuleNotFoundError("No module named 'fake'"), ): with pytest.raises(ModuleNotFoundError): get_safe_module_class(".engines.fake_module", "FakeEngine") def test_attribute_error_propagates(self): """AttributeError propagates when the class is missing from the module.""" with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): mock_module = MagicMock(spec=[]) with patch( "local_deep_research.security.module_whitelist.importlib.import_module", return_value=mock_module, ): with pytest.raises(AttributeError): get_safe_module_class( ".engines.search_engine_brave", "NonExistentClass", )