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,732 @@
"""
Tests for Cycle Detection in Task Dependencies and Formula Fields
Tests cover:
- Task dependency cycle detection (direct and indirect)
- Bulk dependency validation with cycle detection
- Formula field circular reference detection
- Detailed cycle path reporting
"""
import pytest
from unittest.mock import MagicMock
from app.models import Task, TaskDependency, Space, Project, TaskStatus, CustomField
from app.services.dependency_service import (
DependencyService,
DependencyValidationError,
CycleDetectionResult
)
from app.services.formula_service import (
FormulaService,
CircularReferenceError
)
class TestTaskDependencyCycleDetection:
"""Test task dependency cycle detection."""
def setup_project(self, db, project_id: str, space_id: str):
"""Create a space and project for testing."""
space = Space(
id=space_id,
name="Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
)
db.add(space)
project = Project(
id=project_id,
space_id=space_id,
title="Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
security_level="public",
)
db.add(project)
status = TaskStatus(
id=f"status-{project_id}",
project_id=project_id,
name="To Do",
color="#808080",
position=0,
)
db.add(status)
db.commit()
return project, status
def create_task(self, db, task_id: str, project_id: str, status_id: str, title: str):
"""Create a task for testing."""
task = Task(
id=task_id,
project_id=project_id,
title=title,
priority="medium",
created_by="00000000-0000-0000-0000-000000000001",
status_id=status_id,
)
db.add(task)
return task
def test_direct_circular_dependency_A_B_A(self, db):
"""Test detection of direct cycle: A -> B -> A."""
project, status = self.setup_project(db, "proj-cycle-1", "space-cycle-1")
task_a = self.create_task(db, "task-a-1", project.id, status.id, "Task A")
task_b = self.create_task(db, "task-b-1", project.id, status.id, "Task B")
db.commit()
# Create A -> B dependency
dep = TaskDependency(
id="dep-ab-1",
predecessor_id="task-a-1",
successor_id="task-b-1",
dependency_type="FS",
lag_days=0,
)
db.add(dep)
db.commit()
# Try to create B -> A (would create cycle)
result = DependencyService.detect_circular_dependency_detailed(
db, "task-b-1", "task-a-1", project.id
)
assert result.has_cycle is True
assert len(result.cycle_path) > 0
assert "task-a-1" in result.cycle_path
assert "task-b-1" in result.cycle_path
assert "Task A" in result.cycle_task_titles
assert "Task B" in result.cycle_task_titles
def test_indirect_circular_dependency_A_B_C_A(self, db):
"""Test detection of indirect cycle: A -> B -> C -> A."""
project, status = self.setup_project(db, "proj-cycle-2", "space-cycle-2")
task_a = self.create_task(db, "task-a-2", project.id, status.id, "Task A")
task_b = self.create_task(db, "task-b-2", project.id, status.id, "Task B")
task_c = self.create_task(db, "task-c-2", project.id, status.id, "Task C")
db.commit()
# Create A -> B and B -> C dependencies
dep_ab = TaskDependency(
id="dep-ab-2",
predecessor_id="task-a-2",
successor_id="task-b-2",
dependency_type="FS",
lag_days=0,
)
dep_bc = TaskDependency(
id="dep-bc-2",
predecessor_id="task-b-2",
successor_id="task-c-2",
dependency_type="FS",
lag_days=0,
)
db.add_all([dep_ab, dep_bc])
db.commit()
# Try to create C -> A (would create cycle A -> B -> C -> A)
result = DependencyService.detect_circular_dependency_detailed(
db, "task-c-2", "task-a-2", project.id
)
assert result.has_cycle is True
cycle_desc = result.get_cycle_description()
assert "Task A" in cycle_desc
assert "Task B" in cycle_desc
assert "Task C" in cycle_desc
def test_longer_cycle_path(self, db):
"""Test detection of longer cycle: A -> B -> C -> D -> E -> A."""
project, status = self.setup_project(db, "proj-cycle-3", "space-cycle-3")
tasks = []
for letter in ["A", "B", "C", "D", "E"]:
task = self.create_task(
db, f"task-{letter.lower()}-3", project.id, status.id, f"Task {letter}"
)
tasks.append(task)
db.commit()
# Create chain: A -> B -> C -> D -> E
deps = []
task_ids = [f"task-{l.lower()}-3" for l in ["A", "B", "C", "D", "E"]]
for i in range(len(task_ids) - 1):
dep = TaskDependency(
id=f"dep-{i}-3",
predecessor_id=task_ids[i],
successor_id=task_ids[i + 1],
dependency_type="FS",
lag_days=0,
)
deps.append(dep)
db.add_all(deps)
db.commit()
# Try to create E -> A (would create cycle)
result = DependencyService.detect_circular_dependency_detailed(
db, "task-e-3", "task-a-3", project.id
)
assert result.has_cycle is True
assert len(result.cycle_path) >= 5 # Should contain all 5 tasks + repeat
def test_no_cycle_valid_dependency(self, db):
"""Test that valid dependency chains are accepted."""
project, status = self.setup_project(db, "proj-valid-1", "space-valid-1")
task_a = self.create_task(db, "task-a-v1", project.id, status.id, "Task A")
task_b = self.create_task(db, "task-b-v1", project.id, status.id, "Task B")
task_c = self.create_task(db, "task-c-v1", project.id, status.id, "Task C")
db.commit()
# Create A -> B
dep = TaskDependency(
id="dep-ab-v1",
predecessor_id="task-a-v1",
successor_id="task-b-v1",
dependency_type="FS",
lag_days=0,
)
db.add(dep)
db.commit()
# B -> C should be valid (no cycle)
result = DependencyService.detect_circular_dependency_detailed(
db, "task-b-v1", "task-c-v1", project.id
)
assert result.has_cycle is False
assert len(result.cycle_path) == 0
def test_cycle_description_format(self, db):
"""Test that cycle description is formatted correctly."""
project, status = self.setup_project(db, "proj-desc-1", "space-desc-1")
task_a = self.create_task(db, "task-a-d1", project.id, status.id, "Alpha Task")
task_b = self.create_task(db, "task-b-d1", project.id, status.id, "Beta Task")
db.commit()
# Create A -> B
dep = TaskDependency(
id="dep-ab-d1",
predecessor_id="task-a-d1",
successor_id="task-b-d1",
dependency_type="FS",
lag_days=0,
)
db.add(dep)
db.commit()
# Try B -> A
result = DependencyService.detect_circular_dependency_detailed(
db, "task-b-d1", "task-a-d1", project.id
)
description = result.get_cycle_description()
assert " -> " in description # Should use arrow format
class TestBulkDependencyValidation:
"""Test bulk dependency validation with cycle detection."""
def setup_project_with_tasks(self, db, project_id: str, space_id: str, task_count: int):
"""Create a project with multiple tasks."""
space = Space(
id=space_id,
name="Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
)
db.add(space)
project = Project(
id=project_id,
space_id=space_id,
title="Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
security_level="public",
)
db.add(project)
status = TaskStatus(
id=f"status-{project_id}",
project_id=project_id,
name="To Do",
color="#808080",
position=0,
)
db.add(status)
tasks = []
for i in range(task_count):
task = Task(
id=f"task-{project_id}-{i}",
project_id=project_id,
title=f"Task {i}",
priority="medium",
created_by="00000000-0000-0000-0000-000000000001",
status_id=f"status-{project_id}",
)
db.add(task)
tasks.append(task)
db.commit()
return project, tasks
def test_bulk_validation_detects_cycle_in_batch(self, db):
"""Test that bulk validation detects cycles created by the batch itself."""
project, tasks = self.setup_project_with_tasks(db, "proj-bulk-1", "space-bulk-1", 3)
# Create A -> B -> C -> A in a single batch
dependencies = [
(tasks[0].id, tasks[1].id), # A -> B
(tasks[1].id, tasks[2].id), # B -> C
(tasks[2].id, tasks[0].id), # C -> A (creates cycle)
]
errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id)
# Should detect the cycle
assert len(errors) > 0
cycle_errors = [e for e in errors if e.get("error_type") == "circular"]
assert len(cycle_errors) > 0
def test_bulk_validation_accepts_valid_chain(self, db):
"""Test that bulk validation accepts valid dependency chains."""
project, tasks = self.setup_project_with_tasks(db, "proj-bulk-2", "space-bulk-2", 4)
# Create A -> B -> C -> D (valid chain)
dependencies = [
(tasks[0].id, tasks[1].id), # A -> B
(tasks[1].id, tasks[2].id), # B -> C
(tasks[2].id, tasks[3].id), # C -> D
]
errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id)
assert len(errors) == 0
def test_bulk_validation_detects_self_reference(self, db):
"""Test that bulk validation detects self-references."""
project, tasks = self.setup_project_with_tasks(db, "proj-bulk-3", "space-bulk-3", 2)
dependencies = [
(tasks[0].id, tasks[0].id), # Self-reference
]
errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id)
assert len(errors) > 0
assert errors[0]["error_type"] == "self_reference"
def test_bulk_validation_detects_duplicate_in_existing(self, db):
"""Test that bulk validation detects duplicates with existing dependencies."""
project, tasks = self.setup_project_with_tasks(db, "proj-bulk-4", "space-bulk-4", 2)
# Create existing dependency
dep = TaskDependency(
id="dep-existing-bulk-4",
predecessor_id=tasks[0].id,
successor_id=tasks[1].id,
dependency_type="FS",
lag_days=0,
)
db.add(dep)
db.commit()
# Try to add same dependency in bulk
dependencies = [
(tasks[0].id, tasks[1].id), # Duplicate
]
errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id)
assert len(errors) > 0
assert errors[0]["error_type"] == "duplicate"
class TestFormulaFieldCycleDetection:
"""Test formula field circular reference detection."""
def setup_project_with_fields(self, db, project_id: str, space_id: str):
"""Create a project with custom fields."""
space = Space(
id=space_id,
name="Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
)
db.add(space)
project = Project(
id=project_id,
space_id=space_id,
title="Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
security_level="public",
)
db.add(project)
status = TaskStatus(
id=f"status-{project_id}",
project_id=project_id,
name="To Do",
color="#808080",
position=0,
)
db.add(status)
db.commit()
return project
def test_formula_self_reference_detected(self, db):
"""Test that a formula referencing itself is detected."""
project = self.setup_project_with_fields(db, "proj-formula-1", "space-formula-1")
# Create a formula field
field = CustomField(
id="field-self-ref",
project_id=project.id,
name="self_ref_field",
field_type="formula",
formula="{self_ref_field} + 1", # References itself
position=0,
)
db.add(field)
db.commit()
# Validate the formula
is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details(
"{self_ref_field} + 1", project.id, db, field.id
)
assert is_valid is False
assert "self_ref_field" in error_msg or (cycle_path and "self_ref_field" in cycle_path)
def test_formula_indirect_cycle_detected(self, db):
"""Test detection of indirect cycle: A -> B -> A."""
project = self.setup_project_with_fields(db, "proj-formula-2", "space-formula-2")
# Create field B that references field A
field_a = CustomField(
id="field-a-f2",
project_id=project.id,
name="field_a",
field_type="number",
position=0,
)
db.add(field_a)
field_b = CustomField(
id="field-b-f2",
project_id=project.id,
name="field_b",
field_type="formula",
formula="{field_a} * 2",
position=1,
)
db.add(field_b)
db.commit()
# Now try to update field_a to reference field_b (would create cycle)
field_a.field_type = "formula"
field_a.formula = "{field_b} + 1"
db.commit()
is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details(
"{field_b} + 1", project.id, db, field_a.id
)
assert is_valid is False
assert "Circular" in error_msg or (cycle_path is not None and len(cycle_path) > 0)
def test_formula_long_cycle_detected(self, db):
"""Test detection of longer cycle: A -> B -> C -> A."""
project = self.setup_project_with_fields(db, "proj-formula-3", "space-formula-3")
# Create a chain: field_a (number), field_b = {field_a}, field_c = {field_b}
field_a = CustomField(
id="field-a-f3",
project_id=project.id,
name="field_a",
field_type="number",
position=0,
)
field_b = CustomField(
id="field-b-f3",
project_id=project.id,
name="field_b",
field_type="formula",
formula="{field_a} * 2",
position=1,
)
field_c = CustomField(
id="field-c-f3",
project_id=project.id,
name="field_c",
field_type="formula",
formula="{field_b} + 10",
position=2,
)
db.add_all([field_a, field_b, field_c])
db.commit()
# Now try to make field_a reference field_c (would create cycle)
field_a.field_type = "formula"
field_a.formula = "{field_c} / 2"
db.commit()
is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details(
"{field_c} / 2", project.id, db, field_a.id
)
assert is_valid is False
# Should have a cycle path
if cycle_path:
assert len(cycle_path) >= 3
def test_valid_formula_chain_accepted(self, db):
"""Test that valid formula chains are accepted."""
project = self.setup_project_with_fields(db, "proj-formula-4", "space-formula-4")
# Create valid chain: field_a (number), field_b = {field_a}
field_a = CustomField(
id="field-a-f4",
project_id=project.id,
name="field_a",
field_type="number",
position=0,
)
db.add(field_a)
db.commit()
# Validate formula for field_b referencing field_a
is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details(
"{field_a} * 2", project.id, db
)
assert is_valid is True
assert error_msg is None
assert cycle_path is None
def test_builtin_fields_not_cause_cycle(self, db):
"""Test that builtin fields don't cause false cycle detection."""
project = self.setup_project_with_fields(db, "proj-formula-5", "space-formula-5")
# Create formula using builtin fields
field = CustomField(
id="field-builtin-f5",
project_id=project.id,
name="progress",
field_type="formula",
formula="{time_spent} / {original_estimate} * 100",
position=0,
)
db.add(field)
db.commit()
is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details(
"{time_spent} / {original_estimate} * 100", project.id, db, field.id
)
assert is_valid is True
class TestCycleDetectionInGraph:
"""Test cycle detection in existing graphs."""
def test_detect_cycles_in_graph_finds_existing_cycle(self, db):
"""Test that detect_cycles_in_graph finds existing cycles."""
# Create project
space = Space(
id="space-graph-1",
name="Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
)
db.add(space)
project = Project(
id="proj-graph-1",
space_id="space-graph-1",
title="Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
security_level="public",
)
db.add(project)
status = TaskStatus(
id="status-graph-1",
project_id="proj-graph-1",
name="To Do",
color="#808080",
position=0,
)
db.add(status)
# Create tasks
task_a = Task(
id="task-a-graph",
project_id="proj-graph-1",
title="Task A",
priority="medium",
created_by="00000000-0000-0000-0000-000000000001",
status_id="status-graph-1",
)
task_b = Task(
id="task-b-graph",
project_id="proj-graph-1",
title="Task B",
priority="medium",
created_by="00000000-0000-0000-0000-000000000001",
status_id="status-graph-1",
)
db.add_all([task_a, task_b])
# Manually create a cycle (bypassing validation for testing)
dep_ab = TaskDependency(
id="dep-ab-graph",
predecessor_id="task-a-graph",
successor_id="task-b-graph",
dependency_type="FS",
lag_days=0,
)
dep_ba = TaskDependency(
id="dep-ba-graph",
predecessor_id="task-b-graph",
successor_id="task-a-graph",
dependency_type="FS",
lag_days=0,
)
db.add_all([dep_ab, dep_ba])
db.commit()
# Detect cycles
cycles = DependencyService.detect_cycles_in_graph(db, "proj-graph-1")
assert len(cycles) > 0
assert cycles[0].has_cycle is True
def test_detect_cycles_in_graph_empty_when_no_cycles(self, db):
"""Test that detect_cycles_in_graph returns empty when no cycles."""
# Create project
space = Space(
id="space-graph-2",
name="Test Space",
owner_id="00000000-0000-0000-0000-000000000001",
)
db.add(space)
project = Project(
id="proj-graph-2",
space_id="space-graph-2",
title="Test Project",
owner_id="00000000-0000-0000-0000-000000000001",
security_level="public",
)
db.add(project)
status = TaskStatus(
id="status-graph-2",
project_id="proj-graph-2",
name="To Do",
color="#808080",
position=0,
)
db.add(status)
# Create tasks with valid chain
task_a = Task(
id="task-a-graph-2",
project_id="proj-graph-2",
title="Task A",
priority="medium",
created_by="00000000-0000-0000-0000-000000000001",
status_id="status-graph-2",
)
task_b = Task(
id="task-b-graph-2",
project_id="proj-graph-2",
title="Task B",
priority="medium",
created_by="00000000-0000-0000-0000-000000000001",
status_id="status-graph-2",
)
task_c = Task(
id="task-c-graph-2",
project_id="proj-graph-2",
title="Task C",
priority="medium",
created_by="00000000-0000-0000-0000-000000000001",
status_id="status-graph-2",
)
db.add_all([task_a, task_b, task_c])
# Create valid chain A -> B -> C
dep_ab = TaskDependency(
id="dep-ab-graph-2",
predecessor_id="task-a-graph-2",
successor_id="task-b-graph-2",
dependency_type="FS",
lag_days=0,
)
dep_bc = TaskDependency(
id="dep-bc-graph-2",
predecessor_id="task-b-graph-2",
successor_id="task-c-graph-2",
dependency_type="FS",
lag_days=0,
)
db.add_all([dep_ab, dep_bc])
db.commit()
# Detect cycles
cycles = DependencyService.detect_cycles_in_graph(db, "proj-graph-2")
assert len(cycles) == 0
class TestCycleDetectionResultClass:
"""Test CycleDetectionResult class methods."""
def test_cycle_detection_result_no_cycle(self):
"""Test CycleDetectionResult when no cycle."""
result = CycleDetectionResult(has_cycle=False)
assert result.has_cycle is False
assert result.cycle_path == []
assert result.get_cycle_description() == ""
def test_cycle_detection_result_with_cycle(self):
"""Test CycleDetectionResult when cycle exists."""
result = CycleDetectionResult(
has_cycle=True,
cycle_path=["task-a", "task-b", "task-a"],
cycle_task_titles=["Task A", "Task B", "Task A"]
)
assert result.has_cycle is True
assert result.cycle_path == ["task-a", "task-b", "task-a"]
description = result.get_cycle_description()
assert "Task A" in description
assert "Task B" in description
assert " -> " in description
class TestCircularReferenceErrorClass:
"""Test CircularReferenceError class methods."""
def test_circular_reference_error_with_path(self):
"""Test CircularReferenceError with cycle path."""
error = CircularReferenceError(
"Test error",
cycle_path=["field_a", "field_b", "field_a"]
)
assert error.message == "Test error"
assert error.cycle_path == ["field_a", "field_b", "field_a"]
description = error.get_cycle_description()
assert "field_a" in description
assert "field_b" in description
assert " -> " in description
def test_circular_reference_error_without_path(self):
"""Test CircularReferenceError without cycle path."""
error = CircularReferenceError("Test error")
assert error.message == "Test error"
assert error.cycle_path == []
assert error.get_cycle_description() == ""