## 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>
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""
|
|
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
|