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:
beabigegg
2026-01-10 22:13:43 +08:00
parent 96210c7ad4
commit 3bdc6ff1c9
106 changed files with 9704 additions and 429 deletions

View 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