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>
572 lines
19 KiB
Python
572 lines
19 KiB
Python
import pytest
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from app.models import User, Space, Project, Task, TaskStatus, ScheduledReport, ReportHistory, Blocker, ProjectMember
|
|
from app.services.report_service import ReportService
|
|
|
|
|
|
@pytest.fixture
|
|
def test_user(db):
|
|
"""Create a test user."""
|
|
user = User(
|
|
id=str(uuid.uuid4()),
|
|
email="reportuser@example.com",
|
|
name="Report User",
|
|
role_id="00000000-0000-0000-0000-000000000003",
|
|
is_active=True,
|
|
is_system_admin=False,
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def test_user_token(client, mock_redis, test_user):
|
|
"""Get a token for test user."""
|
|
from app.core.security import create_access_token, create_token_payload
|
|
|
|
token_data = create_token_payload(
|
|
user_id=test_user.id,
|
|
email=test_user.email,
|
|
role="engineer",
|
|
department_id=None,
|
|
is_system_admin=False,
|
|
)
|
|
token = create_access_token(token_data)
|
|
mock_redis.setex(f"session:{test_user.id}", 900, token)
|
|
return token
|
|
|
|
|
|
@pytest.fixture
|
|
def test_user_csrf_token(test_user):
|
|
"""Generate a CSRF token for the test user."""
|
|
from app.core.security import generate_csrf_token
|
|
|
|
return generate_csrf_token(test_user.id)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_space(db, test_user):
|
|
"""Create a test space."""
|
|
space = Space(
|
|
id=str(uuid.uuid4()),
|
|
name="Report Test Space",
|
|
description="Test space for reports",
|
|
owner_id=test_user.id,
|
|
)
|
|
db.add(space)
|
|
db.commit()
|
|
return space
|
|
|
|
|
|
@pytest.fixture
|
|
def test_project(db, test_space, test_user):
|
|
"""Create a test project."""
|
|
project = Project(
|
|
id=str(uuid.uuid4()),
|
|
space_id=test_space.id,
|
|
title="Report Test Project",
|
|
description="Test project for reports",
|
|
owner_id=test_user.id,
|
|
)
|
|
db.add(project)
|
|
db.commit()
|
|
return project
|
|
|
|
|
|
@pytest.fixture
|
|
def test_statuses(db, test_project):
|
|
"""Create test task statuses."""
|
|
todo = TaskStatus(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
is_done=False,
|
|
)
|
|
in_progress = TaskStatus(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
name="In Progress",
|
|
color="#0000FF",
|
|
position=1,
|
|
is_done=False,
|
|
)
|
|
done = TaskStatus(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
name="Done",
|
|
color="#00FF00",
|
|
position=2,
|
|
is_done=True,
|
|
)
|
|
db.add_all([todo, in_progress, done])
|
|
db.commit()
|
|
return {"todo": todo, "in_progress": in_progress, "done": done}
|
|
|
|
|
|
@pytest.fixture
|
|
def test_tasks(db, test_project, test_user, test_statuses):
|
|
"""Create test tasks with various statuses."""
|
|
tasks = []
|
|
|
|
# Completed task (updated this week)
|
|
completed_task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Completed Task",
|
|
status_id=test_statuses["done"].id,
|
|
created_by=test_user.id,
|
|
)
|
|
completed_task.updated_at = datetime.utcnow()
|
|
tasks.append(completed_task)
|
|
|
|
# In progress task
|
|
in_progress_task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="In Progress Task",
|
|
status_id=test_statuses["in_progress"].id,
|
|
created_by=test_user.id,
|
|
)
|
|
tasks.append(in_progress_task)
|
|
|
|
# Overdue task
|
|
overdue_task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Overdue Task",
|
|
status_id=test_statuses["todo"].id,
|
|
due_date=datetime.utcnow() - timedelta(days=3),
|
|
created_by=test_user.id,
|
|
)
|
|
tasks.append(overdue_task)
|
|
|
|
db.add_all(tasks)
|
|
db.commit()
|
|
return tasks
|
|
|
|
|
|
class TestReportService:
|
|
"""Tests for ReportService."""
|
|
|
|
def test_get_week_start(self):
|
|
"""Test week start calculation."""
|
|
# Test with a known Wednesday
|
|
wednesday = datetime(2024, 12, 25, 15, 30, 0) # Wednesday
|
|
week_start = ReportService.get_week_start(wednesday)
|
|
|
|
assert week_start.weekday() == 0 # Monday
|
|
assert week_start.hour == 0
|
|
assert week_start.minute == 0
|
|
|
|
def test_get_weekly_stats_empty(self, db, test_user):
|
|
"""Test weekly stats with no projects."""
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert stats["summary"]["completed_count"] == 0
|
|
assert stats["summary"]["in_progress_count"] == 0
|
|
assert stats["summary"]["total_tasks"] == 0
|
|
assert len(stats["projects"]) == 0
|
|
|
|
def test_get_weekly_stats_with_tasks(self, db, test_user, test_project, test_tasks, test_statuses):
|
|
"""Test weekly stats with tasks."""
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert stats["summary"]["completed_count"] == 1
|
|
assert stats["summary"]["in_progress_count"] == 2
|
|
assert stats["summary"]["overdue_count"] == 1
|
|
assert stats["summary"]["total_tasks"] == 3
|
|
assert len(stats["projects"]) == 1
|
|
assert stats["projects"][0]["project_title"] == "Report Test Project"
|
|
|
|
def test_weekly_stats_includes_project_members(self, db, test_user, test_space):
|
|
"""Project member should receive weekly stats for member projects."""
|
|
other_owner = User(
|
|
id=str(uuid.uuid4()),
|
|
email="owner2@example.com",
|
|
name="Other Owner",
|
|
role_id="00000000-0000-0000-0000-000000000003",
|
|
is_active=True,
|
|
is_system_admin=False,
|
|
)
|
|
db.add(other_owner)
|
|
db.commit()
|
|
|
|
member_project = Project(
|
|
id=str(uuid.uuid4()),
|
|
space_id=test_space.id,
|
|
title="Member Project",
|
|
description="Project for member stats",
|
|
owner_id=other_owner.id,
|
|
)
|
|
db.add(member_project)
|
|
db.commit()
|
|
|
|
db.add(ProjectMember(
|
|
id=str(uuid.uuid4()),
|
|
project_id=member_project.id,
|
|
user_id=test_user.id,
|
|
role="member",
|
|
added_by=other_owner.id,
|
|
))
|
|
db.commit()
|
|
|
|
member_status = TaskStatus(
|
|
id=str(uuid.uuid4()),
|
|
project_id=member_project.id,
|
|
name="In Progress",
|
|
color="#0000FF",
|
|
position=0,
|
|
is_done=False,
|
|
)
|
|
db.add(member_status)
|
|
db.commit()
|
|
|
|
task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=member_project.id,
|
|
title="Member Task",
|
|
status_id=member_status.id,
|
|
created_by=other_owner.id,
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
project_titles = {project["project_title"] for project in stats["projects"]}
|
|
|
|
assert "Member Project" in project_titles
|
|
|
|
def test_completed_task_outside_week_not_counted(self, db, test_user, test_project, test_statuses):
|
|
"""Completed tasks outside the week window should not be counted."""
|
|
week_start = ReportService.get_week_start()
|
|
week_end = week_start + timedelta(days=7)
|
|
|
|
task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Completed Outside Week",
|
|
status_id=test_statuses["done"].id,
|
|
created_by=test_user.id,
|
|
)
|
|
task.updated_at = week_end + timedelta(days=1)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id, week_start)
|
|
|
|
assert stats["summary"]["completed_count"] == 0
|
|
|
|
def test_generate_weekly_report(self, db, test_user, test_project, test_tasks, test_statuses):
|
|
"""Test generating a weekly report."""
|
|
report = ReportService.generate_weekly_report(db, test_user.id)
|
|
|
|
assert report is not None
|
|
assert report.status == "sent"
|
|
assert "summary" in report.content
|
|
|
|
# Check scheduled report was created
|
|
scheduled = db.query(ScheduledReport).filter(
|
|
ScheduledReport.recipient_id == test_user.id
|
|
).first()
|
|
assert scheduled is not None
|
|
assert scheduled.last_sent_at is not None
|
|
|
|
|
|
class TestReportAPI:
|
|
"""Tests for Report API endpoints."""
|
|
|
|
def test_preview_weekly_report(self, client, test_user_token, test_project, test_tasks, test_statuses):
|
|
"""Test previewing weekly report."""
|
|
response = client.get(
|
|
"/api/reports/weekly/preview",
|
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "summary" in data
|
|
assert "projects" in data
|
|
assert data["summary"]["total_tasks"] == 3
|
|
|
|
def test_generate_weekly_report_api(self, client, test_user_token, test_user_csrf_token, test_project, test_tasks, test_statuses):
|
|
"""Test generating weekly report via API."""
|
|
response = client.post(
|
|
"/api/reports/weekly/generate",
|
|
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["message"] == "Weekly report generated successfully"
|
|
assert "report_id" in data
|
|
assert "summary" in data
|
|
|
|
def test_weekly_report_subscription_toggle(self, client, test_user_token, test_user_csrf_token, db, test_user):
|
|
"""Test weekly report subscription toggle endpoints."""
|
|
response = client.get(
|
|
"/api/reports/weekly/subscription",
|
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["is_active"] is False
|
|
|
|
response = client.put(
|
|
"/api/reports/weekly/subscription",
|
|
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
|
|
json={"is_active": True},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["is_active"] is True
|
|
|
|
response = client.get(
|
|
"/api/reports/weekly/subscription",
|
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["is_active"] is True
|
|
|
|
response = client.put(
|
|
"/api/reports/weekly/subscription",
|
|
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
|
|
json={"is_active": False},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["is_active"] is False
|
|
|
|
scheduled = db.query(ScheduledReport).filter(
|
|
ScheduledReport.recipient_id == test_user.id,
|
|
ScheduledReport.report_type == "weekly",
|
|
).first()
|
|
assert scheduled is not None
|
|
assert scheduled.is_active is False
|
|
|
|
def test_list_report_history_empty(self, client, test_user_token):
|
|
"""Test listing report history when empty."""
|
|
response = client.get(
|
|
"/api/reports/history",
|
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 0
|
|
assert len(data["reports"]) == 0
|
|
|
|
def test_list_report_history_with_reports(self, client, test_user_token, test_project, test_tasks, test_statuses, db, test_user):
|
|
"""Test listing report history with existing reports."""
|
|
# Generate a report first
|
|
ReportService.generate_weekly_report(db, test_user.id)
|
|
|
|
response = client.get(
|
|
"/api/reports/history",
|
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] >= 1
|
|
assert len(data["reports"]) >= 1
|
|
assert data["reports"][0]["status"] == "sent"
|
|
|
|
def test_get_report_detail(self, client, test_user_token, test_project, test_tasks, test_statuses, db, test_user):
|
|
"""Test getting specific report detail."""
|
|
# Generate a report first
|
|
report = ReportService.generate_weekly_report(db, test_user.id)
|
|
|
|
response = client.get(
|
|
f"/api/reports/history/{report.id}",
|
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == report.id
|
|
assert "content" in data
|
|
|
|
|
|
class TestWeeklyReportContent:
|
|
"""Tests for enhanced weekly report content (blocked/next_week tasks)."""
|
|
|
|
def test_blocked_tasks_included(self, db, test_user, test_project, test_statuses):
|
|
"""Test that blocked tasks are included in weekly stats."""
|
|
# Create a task with blocker
|
|
blocked_task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Blocked Task",
|
|
status_id=test_statuses["in_progress"].id,
|
|
created_by=test_user.id,
|
|
assignee_id=test_user.id,
|
|
)
|
|
db.add(blocked_task)
|
|
db.flush()
|
|
|
|
# Create an unresolved blocker
|
|
blocker = Blocker(
|
|
id=str(uuid.uuid4()),
|
|
task_id=blocked_task.id,
|
|
reported_by=test_user.id,
|
|
reason="Waiting for external dependency",
|
|
)
|
|
db.add(blocker)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert stats["summary"]["blocked_count"] == 1
|
|
assert len(stats["projects"]) == 1
|
|
assert stats["projects"][0]["blocked_count"] == 1
|
|
assert len(stats["projects"][0]["blocked_tasks"]) == 1
|
|
assert stats["projects"][0]["blocked_tasks"][0]["title"] == "Blocked Task"
|
|
assert stats["projects"][0]["blocked_tasks"][0]["blocker_reason"] == "Waiting for external dependency"
|
|
|
|
def test_resolved_blocker_not_included(self, db, test_user, test_project, test_statuses):
|
|
"""Test that resolved blockers are not counted."""
|
|
# Create a task with resolved blocker
|
|
task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Previously Blocked Task",
|
|
status_id=test_statuses["in_progress"].id,
|
|
created_by=test_user.id,
|
|
)
|
|
db.add(task)
|
|
db.flush()
|
|
|
|
# Create a resolved blocker
|
|
blocker = Blocker(
|
|
id=str(uuid.uuid4()),
|
|
task_id=task.id,
|
|
reported_by=test_user.id,
|
|
reason="Was blocked",
|
|
resolved_by=test_user.id,
|
|
resolved_at=datetime.utcnow(),
|
|
resolution_note="Fixed",
|
|
)
|
|
db.add(blocker)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert stats["summary"]["blocked_count"] == 0
|
|
assert stats["projects"][0]["blocked_count"] == 0
|
|
|
|
def test_next_week_tasks_included(self, db, test_user, test_project, test_statuses):
|
|
"""Test that next week tasks are included in weekly stats."""
|
|
# Calculate next week dates
|
|
week_start = ReportService.get_week_start()
|
|
next_week_date = week_start + timedelta(days=10) # Next week
|
|
|
|
# Create a task due next week
|
|
next_week_task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Next Week Task",
|
|
status_id=test_statuses["todo"].id,
|
|
due_date=next_week_date,
|
|
created_by=test_user.id,
|
|
assignee_id=test_user.id,
|
|
)
|
|
db.add(next_week_task)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert stats["summary"]["next_week_count"] == 1
|
|
assert len(stats["projects"][0]["next_week_tasks"]) == 1
|
|
assert stats["projects"][0]["next_week_tasks"][0]["title"] == "Next Week Task"
|
|
|
|
def test_completed_task_not_in_next_week(self, db, test_user, test_project, test_statuses):
|
|
"""Test that completed tasks are not included in next week list."""
|
|
week_start = ReportService.get_week_start()
|
|
next_week_date = week_start + timedelta(days=10)
|
|
|
|
# Create a completed task due next week
|
|
task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Done Next Week Task",
|
|
status_id=test_statuses["done"].id,
|
|
due_date=next_week_date,
|
|
created_by=test_user.id,
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert stats["summary"]["next_week_count"] == 0
|
|
|
|
def test_task_details_include_assignee_name(self, db, test_user, test_project, test_statuses):
|
|
"""Test that task details include assignee name."""
|
|
task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="Assigned Task",
|
|
status_id=test_statuses["in_progress"].id,
|
|
created_by=test_user.id,
|
|
assignee_id=test_user.id,
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert len(stats["projects"][0]["in_progress_tasks"]) == 1
|
|
assert stats["projects"][0]["in_progress_tasks"][0]["assignee_name"] == "Report User"
|
|
|
|
def test_overdue_days_calculated(self, db, test_user, test_project, test_statuses):
|
|
"""Test that days_overdue is correctly calculated."""
|
|
# Create task overdue by 5 days
|
|
overdue_task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title="5 Days Overdue",
|
|
status_id=test_statuses["todo"].id,
|
|
due_date=datetime.utcnow() - timedelta(days=5),
|
|
created_by=test_user.id,
|
|
)
|
|
db.add(overdue_task)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert len(stats["projects"][0]["overdue_tasks"]) == 1
|
|
assert stats["projects"][0]["overdue_tasks"][0]["days_overdue"] >= 5
|
|
|
|
def test_full_task_lists_no_limit(self, db, test_user, test_project, test_statuses):
|
|
"""Test that task lists have no 5-item limit."""
|
|
# Create 10 completed tasks
|
|
for i in range(10):
|
|
task = Task(
|
|
id=str(uuid.uuid4()),
|
|
project_id=test_project.id,
|
|
title=f"Completed Task {i}",
|
|
status_id=test_statuses["done"].id,
|
|
created_by=test_user.id,
|
|
)
|
|
task.updated_at = datetime.utcnow()
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
assert stats["summary"]["completed_count"] == 10
|
|
assert len(stats["projects"][0]["completed_tasks"]) == 10 # No limit
|
|
|
|
def test_summary_includes_all_counts(self, db, test_user, test_project, test_statuses):
|
|
"""Test that summary includes all new count fields."""
|
|
stats = ReportService.get_weekly_stats(db, test_user.id)
|
|
|
|
summary = stats["summary"]
|
|
assert "completed_count" in summary
|
|
assert "in_progress_count" in summary
|
|
assert "overdue_count" in summary
|
|
assert "blocked_count" in summary
|
|
assert "next_week_count" in summary
|
|
assert "total_tasks" in summary
|