Files
PROJECT-CONTORL/backend/tests/test_concurrency_reliability.py
beabigegg 35c90fe76b feat: implement 5 QA-driven security and quality proposals
Implemented proposals from comprehensive QA review:

1. extend-csrf-protection
   - Add POST to CSRF protected methods in frontend
   - Global CSRF middleware for all state-changing operations
   - Update tests with CSRF token fixtures

2. tighten-cors-websocket-security
   - Replace wildcard CORS with explicit method/header lists
   - Disable query parameter auth in production (code 4002)
   - Add per-user WebSocket connection limit (max 5, code 4005)

3. shorten-jwt-expiry
   - Reduce JWT expiry from 7 days to 60 minutes
   - Add refresh token support with 7-day expiry
   - Implement token rotation on refresh
   - Frontend auto-refresh when token near expiry (<5 min)

4. fix-frontend-quality
   - Add React.lazy() code splitting for all pages
   - Fix useCallback dependency arrays (Dashboard, Comments)
   - Add localStorage data validation in AuthContext
   - Complete i18n for AttachmentUpload component

5. enhance-backend-validation
   - Add SecurityAuditMiddleware for access denied logging
   - Add ErrorSanitizerMiddleware for production error messages
   - Protect /health/detailed with admin authentication
   - Add input length validation (comment 5000, desc 10000)

All 521 backend tests passing. Frontend builds successfully.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:19:05 +08:00

315 lines
11 KiB
Python

"""
Tests for concurrency handling and reliability improvements.
Tests cover:
- Optimistic locking with version conflicts
- Trigger retry mechanism
- Cascade restore for soft-deleted tasks
"""
import os
os.environ["TESTING"] = "true"
import pytest
from unittest.mock import patch, MagicMock
from datetime import datetime, timedelta
class TestOptimisticLocking:
"""Test optimistic locking for concurrent updates."""
def test_version_increments_on_update(self, client, admin_token, csrf_token, db):
"""Test that task version increments on successful update."""
from app.models import Space, Project, Task, TaskStatus
# Create test data
space = Space(id="space-1", name="Test Space", owner_id="00000000-0000-0000-0000-000000000001")
db.add(space)
project = Project(id="project-1", name="Test Project", space_id="space-1", owner_id="00000000-0000-0000-0000-000000000001")
db.add(project)
status = TaskStatus(id="status-1", name="To Do", project_id="project-1", position=0)
db.add(status)
task = Task(
id="task-1",
title="Test Task",
project_id="project-1",
status_id="status-1",
created_by="00000000-0000-0000-0000-000000000001",
version=1
)
db.add(task)
db.commit()
# Update task with correct version
response = client.patch(
"/api/tasks/task-1",
json={"title": "Updated Task", "version": 1},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated Task"
assert data["version"] == 2 # Version should increment
def test_version_conflict_returns_409(self, client, admin_token, csrf_token, db):
"""Test that stale version returns 409 Conflict."""
from app.models import Space, Project, Task, TaskStatus
# Create test data
space = Space(id="space-2", name="Test Space 2", owner_id="00000000-0000-0000-0000-000000000001")
db.add(space)
project = Project(id="project-2", name="Test Project 2", space_id="space-2", owner_id="00000000-0000-0000-0000-000000000001")
db.add(project)
status = TaskStatus(id="status-2", name="To Do", project_id="project-2", position=0)
db.add(status)
task = Task(
id="task-2",
title="Test Task",
project_id="project-2",
status_id="status-2",
created_by="00000000-0000-0000-0000-000000000001",
version=5 # Task is at version 5
)
db.add(task)
db.commit()
# Try to update with stale version (1)
response = client.patch(
"/api/tasks/task-2",
json={"title": "Stale Update", "version": 1},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 409
detail = response.json().get("detail")
assert isinstance(detail, dict)
assert detail.get("error") == "conflict"
assert detail.get("current_version") == 5
assert detail.get("provided_version") == 1
def test_update_without_version_succeeds(self, client, admin_token, csrf_token, db):
"""Test that update without version (for backward compatibility) still works."""
from app.models import Space, Project, Task, TaskStatus
# Create test data
space = Space(id="space-3", name="Test Space 3", owner_id="00000000-0000-0000-0000-000000000001")
db.add(space)
project = Project(id="project-3", name="Test Project 3", space_id="space-3", owner_id="00000000-0000-0000-0000-000000000001")
db.add(project)
status = TaskStatus(id="status-3", name="To Do", project_id="project-3", position=0)
db.add(status)
task = Task(
id="task-3",
title="Test Task",
project_id="project-3",
status_id="status-3",
created_by="00000000-0000-0000-0000-000000000001",
version=1
)
db.add(task)
db.commit()
# Update without version field
response = client.patch(
"/api/tasks/task-3",
json={"title": "No Version Update"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
# Should succeed (backward compatibility)
assert response.status_code == 200
class TestTriggerRetryMechanism:
"""Test trigger retry with exponential backoff."""
def test_trigger_scheduler_has_retry_config(self):
"""Test that trigger scheduler has retry configuration."""
from app.services.trigger_scheduler import MAX_RETRIES, BASE_DELAY_SECONDS
# Verify configuration exists
assert MAX_RETRIES == 3
assert BASE_DELAY_SECONDS == 1
def test_retry_mechanism_structure(self):
"""Test that retry mechanism follows exponential backoff pattern."""
from app.services.trigger_scheduler import TriggerSchedulerService
# The service should have the retry method
assert hasattr(TriggerSchedulerService, '_execute_trigger_with_retry')
def test_exponential_backoff_calculation(self):
"""Test exponential backoff delay calculation."""
from app.services.trigger_scheduler import BASE_DELAY_SECONDS
# Verify backoff pattern (1s, 2s, 4s)
delays = [BASE_DELAY_SECONDS * (2 ** i) for i in range(3)]
assert delays == [1, 2, 4]
def test_retry_on_failure_mock(self, db):
"""Test retry behavior using mock."""
from app.services.trigger_scheduler import TriggerSchedulerService
from app.models import ScheduleTrigger
service = TriggerSchedulerService()
call_count = [0]
def mock_execute(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 3:
raise Exception("Transient failure")
return {"success": True}
# Test the retry logic conceptually
# The actual retry happens internally, we verify the config exists
assert hasattr(service, 'execute_trigger') or hasattr(TriggerSchedulerService, '_execute_trigger_with_retry')
class TestCascadeRestore:
"""Test cascade restore for soft-deleted tasks."""
def test_restore_parent_with_children(self, client, admin_token, csrf_token, db):
"""Test restoring parent task also restores children deleted at same time."""
from app.models import Space, Project, Task, TaskStatus
from datetime import datetime
# Create test data
space = Space(id="space-4", name="Test Space 4", owner_id="00000000-0000-0000-0000-000000000001")
db.add(space)
project = Project(id="project-4", name="Test Project 4", space_id="space-4", owner_id="00000000-0000-0000-0000-000000000001")
db.add(project)
status = TaskStatus(id="status-4", name="To Do", project_id="project-4", position=0)
db.add(status)
deleted_time = datetime.utcnow()
parent_task = Task(
id="parent-task",
title="Parent Task",
project_id="project-4",
status_id="status-4",
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=True,
deleted_at=deleted_time
)
db.add(parent_task)
child_task1 = Task(
id="child-task-1",
title="Child Task 1",
project_id="project-4",
status_id="status-4",
parent_task_id="parent-task",
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=True,
deleted_at=deleted_time
)
db.add(child_task1)
child_task2 = Task(
id="child-task-2",
title="Child Task 2",
project_id="project-4",
status_id="status-4",
parent_task_id="parent-task",
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=True,
deleted_at=deleted_time
)
db.add(child_task2)
db.commit()
# Restore parent with cascade=True
response = client.post(
"/api/tasks/parent-task/restore",
json={"cascade": True},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200
data = response.json()
assert data["restored_children_count"] == 2
assert "child-task-1" in data["restored_children_ids"]
assert "child-task-2" in data["restored_children_ids"]
# Verify tasks are restored
db.refresh(parent_task)
db.refresh(child_task1)
db.refresh(child_task2)
assert parent_task.is_deleted is False
assert child_task1.is_deleted is False
assert child_task2.is_deleted is False
def test_restore_parent_only(self, client, admin_token, csrf_token, db):
"""Test restoring parent task without cascade leaves children deleted."""
from app.models import Space, Project, Task, TaskStatus
from datetime import datetime
# Create test data
space = Space(id="space-5", name="Test Space 5", owner_id="00000000-0000-0000-0000-000000000001")
db.add(space)
project = Project(id="project-5", name="Test Project 5", space_id="space-5", owner_id="00000000-0000-0000-0000-000000000001")
db.add(project)
status = TaskStatus(id="status-5", name="To Do", project_id="project-5", position=0)
db.add(status)
deleted_time = datetime.utcnow()
parent_task = Task(
id="parent-task-2",
title="Parent Task 2",
project_id="project-5",
status_id="status-5",
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=True,
deleted_at=deleted_time
)
db.add(parent_task)
child_task = Task(
id="child-task-3",
title="Child Task 3",
project_id="project-5",
status_id="status-5",
parent_task_id="parent-task-2",
created_by="00000000-0000-0000-0000-000000000001",
is_deleted=True,
deleted_at=deleted_time
)
db.add(child_task)
db.commit()
# Restore parent with cascade=False
response = client.post(
"/api/tasks/parent-task-2/restore",
json={"cascade": False},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200
data = response.json()
assert data["restored_children_count"] == 0
# Verify parent restored but child still deleted
db.refresh(parent_task)
db.refresh(child_task)
assert parent_task.is_deleted is False
assert child_task.is_deleted is True