""" Tests for input validation and security enhancements. Tests cover: - Schema input validation (max_length, numeric ranges) - Path traversal prevention - WebSocket authentication flow """ import os os.environ["TESTING"] = "true" import pytest from pydantic import ValidationError from app.schemas.task import TaskCreate, TaskUpdate, TaskBase from app.schemas.project import ProjectCreate from app.schemas.space import SpaceCreate from app.schemas.comment import CommentCreate class TestSchemaInputValidation: """Test input validation for schemas.""" def test_task_title_max_length(self): """Test task title max length validation (500 chars).""" # Valid title valid_task = TaskCreate(title="A" * 500) assert len(valid_task.title) == 500 # Invalid - too long with pytest.raises(ValidationError) as exc_info: TaskCreate(title="A" * 501) assert "String should have at most 500 characters" in str(exc_info.value) def test_task_title_min_length(self): """Test task title min length validation (1 char).""" # Valid - single char valid_task = TaskCreate(title="A") assert valid_task.title == "A" # Invalid - empty string with pytest.raises(ValidationError) as exc_info: TaskCreate(title="") assert "String should have at least 1 character" in str(exc_info.value) def test_task_description_max_length(self): """Test task description max length validation (10000 chars).""" # Valid description valid_task = TaskCreate(title="Test", description="A" * 10000) assert len(valid_task.description) == 10000 # Invalid - too long with pytest.raises(ValidationError) as exc_info: TaskCreate(title="Test", description="A" * 10001) assert "String should have at most 10000 characters" in str(exc_info.value) def test_task_original_estimate_range(self): """Test original_estimate numeric range validation.""" from decimal import Decimal # Valid values task_zero = TaskCreate(title="Test", original_estimate=Decimal("0")) assert task_zero.original_estimate == Decimal("0") task_max = TaskCreate(title="Test", original_estimate=Decimal("99999")) assert task_max.original_estimate == Decimal("99999") # Invalid - negative with pytest.raises(ValidationError) as exc_info: TaskCreate(title="Test", original_estimate=Decimal("-1")) assert "greater than or equal to 0" in str(exc_info.value) # Invalid - too large with pytest.raises(ValidationError) as exc_info: TaskCreate(title="Test", original_estimate=Decimal("100000")) assert "less than or equal to 99999" in str(exc_info.value) def test_task_update_version_validation(self): """Test version field validation for optimistic locking.""" # Valid version update = TaskUpdate(version=1) assert update.version == 1 # Invalid - version 0 with pytest.raises(ValidationError) as exc_info: TaskUpdate(version=0) assert "greater than or equal to 1" in str(exc_info.value) def test_task_position_validation(self): """Test position field validation.""" # Valid position update = TaskUpdate(position=0) assert update.position == 0 # Invalid - negative position with pytest.raises(ValidationError) as exc_info: TaskUpdate(position=-1) assert "greater than or equal to 0" in str(exc_info.value) class TestPathTraversalSecurity: """Test path traversal prevention in file storage.""" def test_path_traversal_detection_in_component(self): """Test that path traversal attempts in components are detected.""" from app.services.file_storage_service import FileStorageService, PathTraversalError service = FileStorageService() # These should raise security exceptions malicious_components = [ "../../../etc/passwd", "..\\..\\windows", "foo/../bar", "test/../../secret", ] for component in malicious_components: with pytest.raises(PathTraversalError) as exc_info: service._validate_path_component(component, "test_component") assert "path traversal" in str(exc_info.value).lower() or "invalid" in str(exc_info.value).lower() def test_path_component_starting_with_dot(self): """Test that components starting with '.' are rejected.""" from app.services.file_storage_service import FileStorageService, PathTraversalError service = FileStorageService() with pytest.raises(PathTraversalError): service._validate_path_component(".hidden", "test") with pytest.raises(PathTraversalError): service._validate_path_component("..parent", "test") def test_valid_path_components_allowed(self): """Test that valid path components are allowed.""" from app.services.file_storage_service import FileStorageService service = FileStorageService() # These should be valid valid_components = [ "project-123", "task_456", "attachment789", "uuid-like-string", ] for component in valid_components: # Should not raise service._validate_path_component(component, "test") def test_path_in_base_dir_validation(self): """Test that paths outside base dir are rejected.""" from app.services.file_storage_service import FileStorageService, PathTraversalError from pathlib import Path service = FileStorageService() # Try to access path outside base directory outside_path = Path("/etc/passwd") with pytest.raises(PathTraversalError): service._validate_path_in_base_dir(outside_path, "test") class TestWebSocketAuthentication: """Test WebSocket authentication flow.""" def test_websocket_requires_auth(self, client): """Test that WebSocket connection requires authentication.""" # Try to connect without sending auth message with pytest.raises(Exception): with client.websocket_connect("/ws/projects/test-project") as websocket: # Should receive error or disconnect without auth data = websocket.receive_json() assert data.get("type") == "error" or "auth" in str(data).lower() def test_websocket_auth_with_valid_token(self, client, admin_token, db): """Test WebSocket connection with valid token in first message.""" from app.models import Space, Project # Create test project space = Space( id="test-space-id", name="Test Space", owner_id="00000000-0000-0000-0000-000000000001" ) db.add(space) project = Project( id="test-project-id", name="Test Project", space_id="test-space-id", owner_id="00000000-0000-0000-0000-000000000001" ) db.add(project) db.commit() # Connect and authenticate with client.websocket_connect("/ws/projects/test-project-id") as websocket: # Send auth message first websocket.send_json({ "type": "auth", "token": admin_token }) # Should receive acknowledgment response = websocket.receive_json() assert response.get("type") in ["authenticated", "sync", "error"] or "connected" in str(response).lower() def test_websocket_auth_with_invalid_token(self, client, db): """Test WebSocket connection with invalid token is rejected.""" from app.models import Space, Project # Create test project space = Space( id="test-space-id-2", name="Test Space 2", owner_id="00000000-0000-0000-0000-000000000001" ) db.add(space) project = Project( id="test-project-id-2", name="Test Project 2", space_id="test-space-id-2", owner_id="00000000-0000-0000-0000-000000000001" ) db.add(project) db.commit() with client.websocket_connect("/ws/projects/test-project-id-2") as websocket: # Send auth message with invalid token websocket.send_json({ "type": "auth", "token": "invalid-token-12345" }) # Should receive error response = websocket.receive_json() assert response.get("type") == "error" or "invalid" in str(response).lower() or "unauthorized" in str(response).lower() class TestInputValidationEdgeCases: """Test edge cases for input validation.""" def test_unicode_in_title(self): """Test that unicode characters are handled correctly.""" # Chinese characters task = TaskCreate(title="測試任務 🎉") assert task.title == "測試任務 🎉" # Japanese task = TaskCreate(title="テストタスク") assert task.title == "テストタスク" # Emojis task = TaskCreate(title="Task with emojis 👍🏻✅🚀") assert "👍" in task.title def test_whitespace_handling(self): """Test whitespace handling in title.""" # Title with only whitespace should fail min_length with pytest.raises(ValidationError): TaskCreate(title=" ") # Spaces only, but length > 0 def test_special_characters_in_description(self): """Test special characters in description.""" special_desc = "\n\t\"quotes\" 'apostrophe'" task = TaskCreate(title="Test", description=special_desc) assert task.description == special_desc # Should store as-is, sanitize on output def test_decimal_precision(self): """Test decimal precision for estimates.""" from decimal import Decimal task = TaskCreate(title="Test", original_estimate=Decimal("123.456789")) assert task.original_estimate == Decimal("123.456789") def test_none_optional_fields(self): """Test that optional fields accept None.""" task = TaskCreate( title="Test", description=None, original_estimate=None, start_date=None, due_date=None ) assert task.description is None assert task.original_estimate is None