feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements
## Security Enhancements (P0) - Add input validation with max_length and numeric range constraints - Implement WebSocket token authentication via first message - Add path traversal prevention in file storage service ## Permission Enhancements (P0) - Add project member management for cross-department access - Implement is_department_manager flag for workload visibility ## Cycle Detection (P0) - Add DFS-based cycle detection for task dependencies - Add formula field circular reference detection - Display user-friendly cycle path visualization ## Concurrency & Reliability (P1) - Implement optimistic locking with version field (409 Conflict on mismatch) - Add trigger retry mechanism with exponential backoff (1s, 2s, 4s) - Implement cascade restore for soft-deleted tasks ## Rate Limiting (P1) - Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min) - Apply rate limits to tasks, reports, attachments, and comments ## Frontend Improvements (P1) - Add responsive sidebar with hamburger menu for mobile - Improve touch-friendly UI with proper tap target sizes - Complete i18n translations for all components ## Backend Reliability (P2) - Configure database connection pool (size=10, overflow=20) - Add Redis fallback mechanism with message queue - Add blocker check before task deletion ## API Enhancements (P3) - Add standardized response wrapper utility - Add /health/ready and /health/live endpoints - Implement project templates with status/field copying ## Tests Added - test_input_validation.py - Schema and path traversal tests - test_concurrency_reliability.py - Optimistic locking and retry tests - test_backend_reliability.py - Connection pool and Redis tests - test_api_enhancements.py - Health check and template tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
291
backend/tests/test_input_validation.py
Normal file
291
backend/tests/test_input_validation.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
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 = "<script>alert('xss')</script>\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
|
||||
Reference in New Issue
Block a user