Skip to main content
Glama
brianirish

Laravel MCP Companion

by brianirish
test_security.py15.5 kB
"""Unit tests for security and safety functions.""" import pytest from pathlib import Path from unittest.mock import patch from laravel_mcp_companion import is_safe_path, get_version_from_path import laravel_mcp_companion class TestPathSecurity: """Test path validation and security functions.""" def test_is_safe_path_within_base(self, temp_dir): """Test that paths within base directory are considered safe.""" base_path = temp_dir safe_paths = [ temp_dir / "file.txt", temp_dir / "subdir" / "file.txt", temp_dir / "a" / "b" / "c" / "file.txt", temp_dir, # Same as base ] for safe_path in safe_paths: assert is_safe_path(base_path, safe_path), f"Path should be safe: {safe_path}" def test_is_safe_path_outside_base(self, temp_dir): """Test that paths outside base directory are considered unsafe.""" base_path = temp_dir / "restricted" base_path.mkdir() unsafe_paths = [ temp_dir / "outside.txt", # Parent directory temp_dir.parent / "file.txt", # Grandparent Path("/etc/passwd"), # Absolute path outside Path("/tmp/file.txt"), # Different root path ] for unsafe_path in unsafe_paths: assert not is_safe_path(base_path, unsafe_path), f"Path should be unsafe: {unsafe_path}" def test_is_safe_path_directory_traversal_attempts(self, temp_dir): """Test that directory traversal attempts are blocked.""" base_path = temp_dir / "docs" base_path.mkdir() # These shouldn't work even if they resolve to safe paths traversal_attempts = [ base_path / ".." / "docs" / "file.txt", # Goes up then down base_path / "subdir" / ".." / ".." / "file.txt", # Multiple traversals ] for traversal_path in traversal_attempts: # The function checks absolute resolved paths, so this should be safe # if it resolves within the base path resolved = traversal_path.resolve() try: result = is_safe_path(base_path, resolved) # Result depends on where the resolved path ends up if base_path in resolved.parents or base_path == resolved: assert result, f"Valid resolved path should be safe: {resolved}" else: assert not result, f"Invalid resolved path should be unsafe: {resolved}" except Exception: # If path resolution fails, that's also a form of protection pass def test_is_safe_path_symlink_protection(self, temp_dir): """Test protection against symlink attacks (if applicable).""" base_path = temp_dir / "docs" base_path.mkdir() # Create a file outside the base outside_file = temp_dir / "outside.txt" outside_file.write_text("sensitive data") # Create a symlink inside base pointing outside try: symlink_path = base_path / "link_to_outside.txt" symlink_path.symlink_to(outside_file) # The symlink itself might be "safe" but its target isn't result = is_safe_path(base_path, symlink_path) # This depends on implementation - the function should check resolved paths assert isinstance(result, bool) # Should not crash except (OSError, NotImplementedError): # Symlinks might not be supported on all systems pytest.skip("Symlinks not supported on this system") def test_is_safe_path_edge_cases(self, temp_dir): """Test edge cases for path validation.""" base_path = temp_dir / "docs" base_path.mkdir() # Empty path components assert is_safe_path(base_path, base_path / "") # Current directory references assert is_safe_path(base_path, base_path / "." / "file.txt") # Very long paths long_subpath = "/".join(["subdir"] * 50) + "/file.txt" long_path = base_path / long_subpath assert is_safe_path(base_path, long_path) class TestVersionParsing: """Test version parsing and validation.""" def test_get_version_from_path_valid_versions(self): """Test version extraction with valid version formats.""" test_cases = [ ("12.x/blade.md", "12.x", "blade.md"), ("11.x/frontend/vite.md", "11.x", "frontend/vite.md"), ("6.x/installation.md", "6.x", "installation.md"), ("10.x/nested/deep/file.md", "10.x", "nested/deep/file.md"), ] for input_path, expected_version, expected_relative in test_cases: version, relative = get_version_from_path(input_path) assert version == expected_version, f"Version mismatch for {input_path}" assert relative == expected_relative, f"Relative path mismatch for {input_path}" def test_get_version_from_path_no_version(self): """Test version extraction when no version is specified.""" with patch('laravel_mcp_companion.DEFAULT_VERSION', '12.x'): test_cases = [ ("blade.md", "12.x", "blade.md"), ("frontend/vite.md", "12.x", "frontend/vite.md"), ("installation.md", "12.x", "installation.md"), ] for input_path, expected_version, expected_relative in test_cases: version, relative = get_version_from_path(input_path) assert version == expected_version, f"Version mismatch for {input_path}" assert relative == expected_relative, f"Relative path mismatch for {input_path}" def test_get_version_from_path_invalid_versions(self): """Test version extraction with invalid version formats.""" with patch('laravel_mcp_companion.DEFAULT_VERSION', '12.x'), \ patch('laravel_mcp_companion.SUPPORTED_VERSIONS', ['6.x', '11.x', '12.x']): # These should fall back to default version test_cases = [ ("99.x/blade.md", "12.x", "99.x/blade.md"), # Unsupported version ("invalid/blade.md", "12.x", "invalid/blade.md"), # Not a version ("master/blade.md", "12.x", "master/blade.md"), # Branch name ] for input_path, expected_version, expected_relative in test_cases: version, relative = get_version_from_path(input_path) assert version == expected_version, f"Version mismatch for {input_path}" assert relative == expected_relative, f"Relative path mismatch for {input_path}" def test_get_version_from_path_empty_input(self): """Test version extraction with empty or invalid input.""" with patch('laravel_mcp_companion.DEFAULT_VERSION', '12.x'): test_cases = [ ("", "12.x", ""), ("/", "12.x", "/"), # Path("/").parts is empty, so it returns "/" (".", "12.x", "."), ] for input_path, expected_version, expected_relative in test_cases: version, relative = get_version_from_path(input_path) assert version == expected_version, f"Version mismatch for {input_path}" assert relative == expected_relative, f"Relative path mismatch for {input_path}" class TestInputValidation: """Test input validation and sanitization.""" def test_search_query_validation(self): """Test search query validation and sanitization.""" # Import the standalone implementation from mcp_tools import search_laravel_docs_impl # Empty queries should be rejected result = search_laravel_docs_impl(Path("/tmp"), "") assert "Search query cannot be empty" in result result = search_laravel_docs_impl(Path("/tmp"), " ") # Whitespace only assert "Search query cannot be empty" in result def test_file_path_validation_in_resource_handlers(self, temp_dir): """Test file path validation in resource handlers.""" # Import the standalone implementation from mcp_tools import read_laravel_doc_content_impl base_docs_path = temp_dir / "docs" base_docs_path.mkdir() version_dir = base_docs_path / "12.x" version_dir.mkdir() # Create a test file test_file = version_dir / "test.md" test_file.write_text("# Test") # Create file outside the version directory outside_file = base_docs_path / "outside.md" outside_file.write_text("# Outside") # Valid file should work result = read_laravel_doc_content_impl(base_docs_path, "test.md", "12.x") assert "# Test" in result # Directory traversal should be blocked - the implementation should handle this # Check that accessing files outside the version directory fails or returns error result = read_laravel_doc_content_impl(base_docs_path, "../outside.md", "12.x") assert "Documentation file not found" in result or "# Outside" not in result def test_package_name_validation(self): """Test package name validation in package functions.""" from laravel_mcp_companion import get_laravel_package_info # Test with valid package names with patch.dict(laravel_mcp_companion.PACKAGE_CATALOG, {'laravel/test': {'name': 'Test', 'description': 'Test package'}}): result = get_laravel_package_info("laravel/test") assert "not found" not in result # Test with invalid package names (should not crash) # Empty string returns "not found" if it doesn't match any package with patch.dict(laravel_mcp_companion.PACKAGE_CATALOG, {}, clear=True): result = get_laravel_package_info("") assert "not found" in result result = get_laravel_package_info("invalid/package/name/with/too/many/parts") assert "not found" in result def test_version_parameter_validation(self): """Test version parameter validation.""" from mcp_tools import read_laravel_doc_content_impl # Test with None version (should use default) with patch('mcp_tools.DEFAULT_VERSION', '12.x'): result = read_laravel_doc_content_impl(Path("/tmp"), "test.md", None) # Should not crash and should handle gracefully assert isinstance(result, str) # Test with empty version result = read_laravel_doc_content_impl(Path("/tmp"), "test.md", "") assert isinstance(result, str) # Test with very long version string - this should fail gracefully long_version = "x" * 1000 try: result = read_laravel_doc_content_impl(Path("/tmp"), "test.md", long_version) # If it succeeds, it should return a string (likely an error message) assert isinstance(result, str) except OSError: # OSError for file name too long is acceptable pass class TestErrorHandling: """Test error handling in security-critical functions.""" def test_is_safe_path_with_non_existent_paths(self, temp_dir): """Test is_safe_path with non-existent paths.""" base_path = temp_dir / "docs" base_path.mkdir() non_existent_paths = [ base_path / "does_not_exist.txt", base_path / "subdir" / "does_not_exist.txt", temp_dir / "outside" / "does_not_exist.txt", ] for path in non_existent_paths: # Function should not crash with non-existent paths try: result = is_safe_path(base_path, path) assert isinstance(result, bool) except Exception as e: pytest.fail(f"is_safe_path crashed with non-existent path {path}: {e}") def test_path_validation_with_special_characters(self, temp_dir): """Test path validation with special characters and encoding.""" base_path = temp_dir / "docs" base_path.mkdir() special_paths = [ base_path / "file with spaces.txt", base_path / "file-with-dashes.txt", base_path / "file_with_underscores.txt", base_path / "file.with.dots.txt", ] # Try to create Unicode filenames (may not work on all systems) try: unicode_path = base_path / "файл.txt" # Cyrillic special_paths.append(unicode_path) except Exception: pass # Unicode filenames not supported for path in special_paths: try: result = is_safe_path(base_path, path) assert isinstance(result, bool) # All should be safe since they're within base_path assert result is True, f"Special character path should be safe: {path}" except Exception as e: # Some special characters might not be supported pytest.skip(f"Special character not supported: {path} - {e}") def test_error_handling_in_get_version_from_path(self): """Test error handling in version parsing.""" # The function should handle string inputs, but test edge cases edge_cases = [ "12.x" + "/" * 1000 + "file.md", # Very long path "12.x/" + "a" * 1000, # Very long filename ] with patch('laravel_mcp_companion.DEFAULT_VERSION', '12.x'): for test_input in edge_cases: try: version, relative = get_version_from_path(test_input) assert isinstance(version, str) assert isinstance(relative, str) except Exception as e: pytest.fail(f"get_version_from_path crashed with input {test_input}: {e}") def test_path_injection_attempts(self, temp_dir): """Test protection against path injection attempts.""" base_path = temp_dir / "docs" base_path.mkdir() # Various injection attempts injection_attempts = [ "../../../etc/passwd", "..\\..\\..\\windows\\system32\\config\\sam", # Windows-style "file.txt\x00extra_data", # Null byte injection "file.txt/../../../sensitive", "./../../sensitive", "subdir/../../sensitive", ] for attempt in injection_attempts: try: test_path = base_path / attempt result = is_safe_path(base_path, test_path) # Should either be False (unsafe) or handle the injection safely if result: # If it's True, the resolved path must actually be safe resolved = test_path.resolve() assert base_path in resolved.parents or base_path == resolved except Exception: # Exceptions during path resolution are also acceptable # (they prevent the injection from succeeding) pass

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/brianirish/laravel-mcp-companion'

If you have feedback or need assistance with the MCP directory API, please join our Discord server