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>
This commit is contained in:
beabigegg
2026-01-12 23:19:05 +08:00
parent df50d5e7f8
commit 35c90fe76b
48 changed files with 2132 additions and 403 deletions

View File

@@ -166,3 +166,20 @@ def admin_token(client, mock_redis):
mock_redis.setex("session:00000000-0000-0000-0000-000000000001", 900, token)
return token
@pytest.fixture
def csrf_token():
"""Generate a CSRF token for the admin user."""
from app.core.security import generate_csrf_token
return generate_csrf_token("00000000-0000-0000-0000-000000000001")
@pytest.fixture
def auth_headers(admin_token, csrf_token):
"""Get complete auth headers including both Authorization and CSRF token."""
return {
"Authorization": f"Bearer {admin_token}",
"X-CSRF-Token": csrf_token,
}

View File

@@ -173,7 +173,7 @@ class TestProjectTemplates:
# Should return list of templates
assert "templates" in data or isinstance(data, list)
def test_create_template(self, client, admin_token, db):
def test_create_template(self, client, auth_headers, db):
"""Test creating a new project template."""
from app.models import Space
@@ -192,14 +192,14 @@ class TestProjectTemplates:
{"name": "Done", "color": "#00FF00"}
]
},
headers={"Authorization": f"Bearer {admin_token}"}
headers=auth_headers
)
assert response.status_code in [200, 201]
data = response.json()
assert data.get("name") == "Test Template"
def test_create_project_from_template(self, client, admin_token, db):
def test_create_project_from_template(self, client, auth_headers, db):
"""Test creating a project from a template."""
from app.models import Space, ProjectTemplate
@@ -228,14 +228,14 @@ class TestProjectTemplates:
"description": "Created from template",
"template_id": "test-template-id"
},
headers={"Authorization": f"Bearer {admin_token}"}
headers=auth_headers
)
assert response.status_code in [200, 201]
data = response.json()
assert data.get("name") == "Project from Template"
def test_delete_template(self, client, admin_token, db):
def test_delete_template(self, client, auth_headers, db):
"""Test deleting a project template."""
from app.models import ProjectTemplate
@@ -251,7 +251,7 @@ class TestProjectTemplates:
response = client.delete(
"/api/templates/delete-template-id",
headers={"Authorization": f"Bearer {admin_token}"}
headers=auth_headers
)
assert response.status_code in [200, 204]

View File

@@ -42,6 +42,22 @@ def test_user_token(client, mock_redis, test_user):
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_user_auth_headers(test_user_token, test_user_csrf_token):
"""Get complete auth headers for test user."""
return {
"Authorization": f"Bearer {test_user_token}",
"X-CSRF-Token": test_user_csrf_token,
}
@pytest.fixture
def test_space(db, test_user):
"""Create a test space."""
@@ -154,7 +170,7 @@ class TestFileStorageService:
class TestAttachmentAPI:
"""Tests for Attachment API endpoints."""
def test_upload_attachment(self, client, test_user_token, test_task, db, monkeypatch, temp_upload_dir):
def test_upload_attachment(self, client, test_user_auth_headers, test_task, db, monkeypatch, temp_upload_dir):
"""Test uploading an attachment."""
monkeypatch.setattr("app.core.config.settings.UPLOAD_DIR", temp_upload_dir)
@@ -163,7 +179,7 @@ class TestAttachmentAPI:
response = client.post(
f"/api/tasks/{test_task.id}/attachments",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
files=files,
)
@@ -271,14 +287,14 @@ class TestAttachmentAPI:
db.refresh(attachment)
assert attachment.is_deleted == True
def test_upload_blocked_file_type(self, client, test_user_token, test_task):
def test_upload_blocked_file_type(self, client, test_user_auth_headers, test_task):
"""Test that blocked file types are rejected."""
content = b"malicious content"
files = {"file": ("virus.exe", BytesIO(content), "application/octet-stream")}
response = client.post(
f"/api/tasks/{test_task.id}/attachments",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
files=files,
)
@@ -322,7 +338,7 @@ class TestAttachmentAPI:
assert data["total"] == 2
assert len(data["versions"]) == 2
def test_restore_version(self, client, test_user_token, test_task, db):
def test_restore_version(self, client, test_user_auth_headers, test_task, db):
"""Test restoring to a previous version."""
attachment = Attachment(
id=str(uuid.uuid4()),
@@ -351,7 +367,7 @@ class TestAttachmentAPI:
response = client.post(
f"/api/attachments/{attachment.id}/restore/1",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
)
assert response.status_code == 200

View File

@@ -253,7 +253,7 @@ class TestAuditAPI:
assert data["total"] == 3
assert all(log["resource_id"] == resource_id for log in data["logs"])
def test_verify_integrity(self, client, admin_token, db):
def test_verify_integrity(self, client, auth_headers, db):
"""Test integrity verification."""
now = datetime.utcnow()
@@ -270,7 +270,7 @@ class TestAuditAPI:
response = client.post(
"/api/audit-logs/verify-integrity",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={
"start_date": (now - timedelta(hours=1)).isoformat(),
"end_date": (now + timedelta(hours=1)).isoformat(),
@@ -281,7 +281,7 @@ class TestAuditAPI:
assert data["total_checked"] >= 1
assert data["invalid_count"] == 0
def test_acknowledge_alert(self, client, admin_token, db):
def test_acknowledge_alert(self, client, auth_headers, db):
"""Test acknowledging an alert."""
# Create a log and alert
log = AuditLog(
@@ -309,7 +309,7 @@ class TestAuditAPI:
response = client.put(
f"/api/audit-alerts/{alert.id}/acknowledge",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()

View File

@@ -1,5 +1,16 @@
import pytest
from app.core.security import create_access_token, decode_access_token, create_token_payload
from app.core.security import (
create_access_token,
decode_access_token,
create_token_payload,
generate_refresh_token,
store_refresh_token,
validate_refresh_token,
invalidate_refresh_token,
invalidate_all_user_refresh_tokens,
decode_refresh_token_user_id,
get_refresh_token_key,
)
class TestJWT:
@@ -59,7 +70,7 @@ class TestAuthEndpoints:
def test_get_me_without_auth(self, client):
"""Test accessing /me without authentication."""
response = client.get("/api/auth/me")
assert response.status_code == 403
assert response.status_code == 401 # 401 for unauthenticated, 403 for unauthorized
def test_get_me_with_auth(self, client, admin_token):
"""Test accessing /me with valid authentication."""
@@ -72,13 +83,196 @@ class TestAuthEndpoints:
assert data["email"] == "ymirliu@panjit.com.tw"
assert data["is_system_admin"] is True
def test_logout(self, client, admin_token, mock_redis):
def test_logout(self, client, auth_headers, mock_redis):
"""Test logout endpoint."""
response = client.post(
"/api/auth/logout",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
# Verify session is removed
assert mock_redis.get("session:00000000-0000-0000-0000-000000000001") is None
class TestRefreshToken:
"""Test refresh token functionality."""
def test_generate_refresh_token(self):
"""Test that refresh tokens are generated correctly."""
token = generate_refresh_token()
assert token is not None
assert isinstance(token, str)
assert len(token) > 20 # URL-safe base64 encoded 32 bytes
def test_generate_unique_refresh_tokens(self):
"""Test that each generated token is unique."""
tokens = [generate_refresh_token() for _ in range(100)]
assert len(set(tokens)) == 100 # All tokens should be unique
def test_store_and_validate_refresh_token(self, mock_redis):
"""Test storing and validating refresh tokens."""
user_id = "test-user-123"
token = generate_refresh_token()
# Store the token
store_refresh_token(mock_redis, user_id, token)
# Validate the token
assert validate_refresh_token(mock_redis, user_id, token) is True
# Wrong user should fail
assert validate_refresh_token(mock_redis, "wrong-user", token) is False
# Wrong token should fail
assert validate_refresh_token(mock_redis, user_id, "wrong-token") is False
def test_invalidate_refresh_token(self, mock_redis):
"""Test invalidating a refresh token."""
user_id = "test-user-123"
token = generate_refresh_token()
# Store and verify
store_refresh_token(mock_redis, user_id, token)
assert validate_refresh_token(mock_redis, user_id, token) is True
# Invalidate
result = invalidate_refresh_token(mock_redis, user_id, token)
assert result is True
# Should no longer be valid
assert validate_refresh_token(mock_redis, user_id, token) is False
def test_invalidate_all_user_refresh_tokens(self, mock_redis):
"""Test invalidating all refresh tokens for a user."""
user_id = "test-user-123"
tokens = [generate_refresh_token() for _ in range(3)]
# Store multiple tokens
for token in tokens:
store_refresh_token(mock_redis, user_id, token)
# Verify all are valid
for token in tokens:
assert validate_refresh_token(mock_redis, user_id, token) is True
# Invalidate all
count = invalidate_all_user_refresh_tokens(mock_redis, user_id)
assert count == 3
# All should be invalid now
for token in tokens:
assert validate_refresh_token(mock_redis, user_id, token) is False
def test_decode_refresh_token_user_id(self, mock_redis):
"""Test finding user ID from refresh token."""
user_id = "test-user-456"
token = generate_refresh_token()
# Store the token
store_refresh_token(mock_redis, user_id, token)
# Find user ID
found_user_id = decode_refresh_token_user_id(token, mock_redis)
assert found_user_id == user_id
# Invalid token should return None
assert decode_refresh_token_user_id("invalid-token", mock_redis) is None
class TestRefreshTokenEndpoint:
"""Test the refresh token API endpoint."""
def test_refresh_token_success(self, client, db, mock_redis):
"""Test successful token refresh."""
user_id = "00000000-0000-0000-0000-000000000001"
# Generate and store a refresh token
refresh_token = generate_refresh_token()
store_refresh_token(mock_redis, user_id, refresh_token)
# Call refresh endpoint
response = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert data["expires_in"] > 0
# Old refresh token should be invalidated (rotation)
assert validate_refresh_token(mock_redis, user_id, refresh_token) is False
# New refresh token should be valid
assert validate_refresh_token(mock_redis, user_id, data["refresh_token"]) is True
def test_refresh_token_invalid(self, client, mock_redis):
"""Test refresh with invalid token."""
response = client.post(
"/api/auth/refresh",
json={"refresh_token": "invalid-token"},
)
assert response.status_code == 401
assert "Invalid or expired refresh token" in response.json()["detail"]
def test_refresh_token_rotation(self, client, db, mock_redis):
"""Test that refresh tokens are rotated (old one invalidated)."""
user_id = "00000000-0000-0000-0000-000000000001"
# Generate and store initial refresh token
initial_token = generate_refresh_token()
store_refresh_token(mock_redis, user_id, initial_token)
# First refresh
response1 = client.post(
"/api/auth/refresh",
json={"refresh_token": initial_token},
)
assert response1.status_code == 200
new_token = response1.json()["refresh_token"]
# Try to reuse the old token (should fail due to rotation)
response2 = client.post(
"/api/auth/refresh",
json={"refresh_token": initial_token},
)
assert response2.status_code == 401
# New token should still work
response3 = client.post(
"/api/auth/refresh",
json={"refresh_token": new_token},
)
assert response3.status_code == 200
def test_refresh_token_disabled_user(self, client, db, mock_redis):
"""Test that disabled users cannot refresh tokens."""
from app.models.user import User
# Create a disabled user
disabled_user = User(
id="disabled-user-123",
email="disabled@example.com",
name="Disabled User",
is_active=False,
)
db.add(disabled_user)
db.commit()
# Generate and store refresh token for disabled user
refresh_token = generate_refresh_token()
store_refresh_token(mock_redis, disabled_user.id, refresh_token)
# Try to refresh
response = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert response.status_code == 403
assert "disabled" in response.json()["detail"].lower()

View File

@@ -128,7 +128,7 @@ class TestRedisFailover:
class TestBlockerDeletionCheck:
"""Test blocker check before task deletion."""
def test_delete_task_with_blockers_warning(self, client, admin_token, db):
def test_delete_task_with_blockers_warning(self, client, admin_token, csrf_token, db):
"""Test that deleting task with blockers shows warning."""
from app.models import Space, Project, Task, TaskStatus, TaskDependency
@@ -174,7 +174,7 @@ class TestBlockerDeletionCheck:
# Try to delete without force
response = client.delete(
"/api/tasks/blocker-task",
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
# Should return warning or require confirmation
@@ -185,7 +185,7 @@ class TestBlockerDeletionCheck:
if "warning" in data or "blocker_count" in data:
assert data.get("blocker_count", 0) >= 1 or "blocker" in str(data).lower()
def test_force_delete_resolves_blockers(self, client, admin_token, db):
def test_force_delete_resolves_blockers(self, client, admin_token, csrf_token, db):
"""Test that force delete resolves blockers."""
from app.models import Space, Project, Task, TaskStatus, TaskDependency
@@ -231,7 +231,7 @@ class TestBlockerDeletionCheck:
# Force delete
response = client.delete(
"/api/tasks/force-del-task?force_delete=true",
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200
@@ -240,7 +240,7 @@ class TestBlockerDeletionCheck:
db.refresh(task_to_delete)
assert task_to_delete.is_deleted is True
def test_delete_task_without_blockers(self, client, admin_token, db):
def test_delete_task_without_blockers(self, client, admin_token, csrf_token, db):
"""Test deleting task without blockers succeeds normally."""
from app.models import Space, Project, Task, TaskStatus
@@ -267,7 +267,7 @@ class TestBlockerDeletionCheck:
# Delete should succeed without warning
response = client.delete(
"/api/tasks/no-blocker-task",
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200

View File

@@ -36,6 +36,13 @@ def user_token(client, mock_redis, test_user):
return token
@pytest.fixture
def 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):
"""Create a test space."""
@@ -100,11 +107,11 @@ def test_task(db, test_project, test_status):
class TestComments:
"""Tests for Comments API."""
def test_create_comment(self, client, admin_token, test_task):
def test_create_comment(self, client, auth_headers, test_task):
"""Test creating a comment."""
response = client.post(
f"/api/tasks/{test_task.id}/comments",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"content": "This is a test comment"},
)
assert response.status_code == 201
@@ -136,7 +143,7 @@ class TestComments:
assert len(data["comments"]) == 1
assert data["comments"][0]["content"] == "Test comment"
def test_update_comment(self, client, admin_token, db, test_task):
def test_update_comment(self, client, auth_headers, db, test_task):
"""Test updating a comment."""
comment = Comment(
id=str(uuid.uuid4()),
@@ -149,7 +156,7 @@ class TestComments:
response = client.put(
f"/api/comments/{comment.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"content": "Updated content"},
)
assert response.status_code == 200
@@ -157,7 +164,7 @@ class TestComments:
assert data["content"] == "Updated content"
assert data["is_edited"] is True
def test_delete_comment(self, client, admin_token, db, test_task):
def test_delete_comment(self, client, auth_headers, db, test_task):
"""Test deleting a comment (soft delete)."""
comment = Comment(
id=str(uuid.uuid4()),
@@ -170,7 +177,7 @@ class TestComments:
response = client.delete(
f"/api/comments/{comment.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 204
@@ -178,13 +185,13 @@ class TestComments:
db.refresh(comment)
assert comment.is_deleted is True
def test_mention_limit(self, client, admin_token, test_task):
def test_mention_limit(self, client, auth_headers, test_task):
"""Test that @mention limit is enforced."""
# Create content with more than 10 mentions
mentions = " ".join([f"@user{i}" for i in range(15)])
response = client.post(
f"/api/tasks/{test_task.id}/comments",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"content": f"Test with many mentions: {mentions}"},
)
assert response.status_code == 400
@@ -218,7 +225,7 @@ class TestNotifications:
assert data["total"] >= 1
assert data["unread_count"] >= 1
def test_mark_notification_as_read(self, client, admin_token, db):
def test_mark_notification_as_read(self, client, auth_headers, db):
"""Test marking a notification as read."""
notification = Notification(
id=str(uuid.uuid4()),
@@ -233,14 +240,14 @@ class TestNotifications:
response = client.put(
f"/api/notifications/{notification.id}/read",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["is_read"] is True
assert data["read_at"] is not None
def test_mark_all_as_read(self, client, admin_token, db):
def test_mark_all_as_read(self, client, auth_headers, db):
"""Test marking all notifications as read."""
# Create multiple unread notifications
for i in range(3):
@@ -257,7 +264,7 @@ class TestNotifications:
response = client.put(
"/api/notifications/read-all",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
@@ -290,11 +297,11 @@ class TestNotifications:
class TestBlockers:
"""Tests for Blockers API."""
def test_create_blocker(self, client, admin_token, test_task):
def test_create_blocker(self, client, auth_headers, test_task):
"""Test creating a blocker."""
response = client.post(
f"/api/tasks/{test_task.id}/blockers",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"reason": "Waiting for external dependency"},
)
assert response.status_code == 201
@@ -302,7 +309,7 @@ class TestBlockers:
assert data["reason"] == "Waiting for external dependency"
assert data["resolved_at"] is None
def test_resolve_blocker(self, client, admin_token, db, test_task):
def test_resolve_blocker(self, client, auth_headers, db, test_task):
"""Test resolving a blocker."""
blocker = Blocker(
id=str(uuid.uuid4()),
@@ -316,7 +323,7 @@ class TestBlockers:
response = client.put(
f"/api/blockers/{blocker.id}/resolve",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"resolution_note": "Issue resolved by updating config"},
)
assert response.status_code == 200
@@ -348,7 +355,7 @@ class TestBlockers:
assert data["total"] == 1
assert data["blockers"][0]["reason"] == "Test blocker"
def test_cannot_create_duplicate_active_blocker(self, client, admin_token, db, test_task):
def test_cannot_create_duplicate_active_blocker(self, client, auth_headers, db, test_task):
"""Test that duplicate active blockers are prevented."""
# Create first blocker
blocker = Blocker(
@@ -363,7 +370,7 @@ class TestBlockers:
# Try to create second blocker
response = client.post(
f"/api/tasks/{test_task.id}/blockers",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"reason": "Second blocker"},
)
assert response.status_code == 400

View File

@@ -18,7 +18,7 @@ from datetime import datetime, timedelta
class TestOptimisticLocking:
"""Test optimistic locking for concurrent updates."""
def test_version_increments_on_update(self, client, admin_token, db):
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
@@ -47,7 +47,7 @@ class TestOptimisticLocking:
response = client.patch(
"/api/tasks/task-1",
json={"title": "Updated Task", "version": 1},
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200
@@ -55,7 +55,7 @@ class TestOptimisticLocking:
assert data["title"] == "Updated Task"
assert data["version"] == 2 # Version should increment
def test_version_conflict_returns_409(self, client, admin_token, db):
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
@@ -84,7 +84,7 @@ class TestOptimisticLocking:
response = client.patch(
"/api/tasks/task-2",
json={"title": "Stale Update", "version": 1},
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 409
@@ -94,7 +94,7 @@ class TestOptimisticLocking:
assert detail.get("current_version") == 5
assert detail.get("provided_version") == 1
def test_update_without_version_succeeds(self, client, admin_token, db):
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
@@ -123,7 +123,7 @@ class TestOptimisticLocking:
response = client.patch(
"/api/tasks/task-3",
json={"title": "No Version Update"},
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
# Should succeed (backward compatibility)
@@ -179,7 +179,7 @@ class TestTriggerRetryMechanism:
class TestCascadeRestore:
"""Test cascade restore for soft-deleted tasks."""
def test_restore_parent_with_children(self, client, admin_token, db):
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
@@ -236,7 +236,7 @@ class TestCascadeRestore:
response = client.post(
"/api/tasks/parent-task/restore",
json={"cascade": True},
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200
@@ -254,7 +254,7 @@ class TestCascadeRestore:
assert child_task1.is_deleted is False
assert child_task2.is_deleted is False
def test_restore_parent_only(self, client, admin_token, db):
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
@@ -299,7 +299,7 @@ class TestCascadeRestore:
response = client.post(
"/api/tasks/parent-task-2/restore",
json={"cascade": False},
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token}
)
assert response.status_code == 200

View File

@@ -39,7 +39,7 @@ class TestCustomFieldsCRUD:
db.commit()
return project
def test_create_text_field(self, client, db, admin_token):
def test_create_text_field(self, client, db, auth_headers):
"""Test creating a text custom field."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -50,7 +50,7 @@ class TestCustomFieldsCRUD:
"field_type": "text",
"is_required": False,
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 201
@@ -59,7 +59,7 @@ class TestCustomFieldsCRUD:
assert data["field_type"] == "text"
assert data["is_required"] is False
def test_create_number_field(self, client, db, admin_token):
def test_create_number_field(self, client, db, auth_headers):
"""Test creating a number custom field."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -70,7 +70,7 @@ class TestCustomFieldsCRUD:
"field_type": "number",
"is_required": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 201
@@ -79,7 +79,7 @@ class TestCustomFieldsCRUD:
assert data["field_type"] == "number"
assert data["is_required"] is True
def test_create_dropdown_field(self, client, db, admin_token):
def test_create_dropdown_field(self, client, db, auth_headers):
"""Test creating a dropdown custom field."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -91,7 +91,7 @@ class TestCustomFieldsCRUD:
"options": ["Frontend", "Backend", "Database", "API"],
"is_required": False,
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 201
@@ -100,7 +100,7 @@ class TestCustomFieldsCRUD:
assert data["field_type"] == "dropdown"
assert data["options"] == ["Frontend", "Backend", "Database", "API"]
def test_create_dropdown_field_without_options_fails(self, client, db, admin_token):
def test_create_dropdown_field_without_options_fails(self, client, db, auth_headers):
"""Test that creating a dropdown field without options fails."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -111,12 +111,12 @@ class TestCustomFieldsCRUD:
"field_type": "dropdown",
"options": [],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 422 # Validation error
def test_create_formula_field(self, client, db, admin_token):
def test_create_formula_field(self, client, db, auth_headers):
"""Test creating a formula custom field."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -127,7 +127,7 @@ class TestCustomFieldsCRUD:
"name": "hours_worked",
"field_type": "number",
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Create formula field
@@ -138,7 +138,7 @@ class TestCustomFieldsCRUD:
"field_type": "formula",
"formula": "{time_spent} / {original_estimate} * 100",
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 201
@@ -147,7 +147,7 @@ class TestCustomFieldsCRUD:
assert data["field_type"] == "formula"
assert "{time_spent}" in data["formula"]
def test_list_custom_fields(self, client, db, admin_token):
def test_list_custom_fields(self, client, db, auth_headers):
"""Test listing custom fields for a project."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -155,17 +155,17 @@ class TestCustomFieldsCRUD:
client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "Field 1", "field_type": "text"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "Field 2", "field_type": "number"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
response = client.get(
f"/api/projects/{project.id}/custom-fields",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
@@ -173,7 +173,7 @@ class TestCustomFieldsCRUD:
assert data["total"] == 2
assert len(data["fields"]) == 2
def test_update_custom_field(self, client, db, admin_token):
def test_update_custom_field(self, client, db, auth_headers):
"""Test updating a custom field."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -181,7 +181,7 @@ class TestCustomFieldsCRUD:
create_response = client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "Original Name", "field_type": "text"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
field_id = create_response.json()["id"]
@@ -189,7 +189,7 @@ class TestCustomFieldsCRUD:
response = client.put(
f"/api/custom-fields/{field_id}",
json={"name": "Updated Name", "is_required": True},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
@@ -197,7 +197,7 @@ class TestCustomFieldsCRUD:
assert data["name"] == "Updated Name"
assert data["is_required"] is True
def test_delete_custom_field(self, client, db, admin_token):
def test_delete_custom_field(self, client, db, auth_headers):
"""Test deleting a custom field."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -205,14 +205,14 @@ class TestCustomFieldsCRUD:
create_response = client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "To Delete", "field_type": "text"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
field_id = create_response.json()["id"]
# Delete it
response = client.delete(
f"/api/custom-fields/{field_id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 204
@@ -220,11 +220,11 @@ class TestCustomFieldsCRUD:
# Verify it's gone
get_response = client.get(
f"/api/custom-fields/{field_id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert get_response.status_code == 404
def test_max_fields_limit(self, client, db, admin_token):
def test_max_fields_limit(self, client, db, auth_headers):
"""Test that maximum 20 custom fields per project is enforced."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -233,7 +233,7 @@ class TestCustomFieldsCRUD:
response = client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": f"Field {i}", "field_type": "text"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 201
@@ -241,12 +241,12 @@ class TestCustomFieldsCRUD:
response = client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "Field 21", "field_type": "text"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 400
assert "Maximum" in response.json()["detail"]
def test_duplicate_name_rejected(self, client, db, admin_token):
def test_duplicate_name_rejected(self, client, db, auth_headers):
"""Test that duplicate field names are rejected."""
project = self.setup_project(db, "00000000-0000-0000-0000-000000000001")
@@ -254,14 +254,14 @@ class TestCustomFieldsCRUD:
client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "Unique Name", "field_type": "text"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Try to create another with same name
response = client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "Unique Name", "field_type": "number"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 400
assert "already exists" in response.json()["detail"]
@@ -311,7 +311,7 @@ class TestFormulaService:
class TestCustomValuesWithTasks:
"""Test custom values integration with tasks."""
def setup_project_with_fields(self, db, client, admin_token, owner_id: str):
def setup_project_with_fields(self, db, client, auth_headers, owner_id: str):
"""Create a project with custom fields for testing."""
space = Space(
id="test-space-002",
@@ -342,23 +342,23 @@ class TestCustomValuesWithTasks:
text_response = client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "sprint_number", "field_type": "text"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
text_field_id = text_response.json()["id"]
number_response = client.post(
f"/api/projects/{project.id}/custom-fields",
json={"name": "story_points", "field_type": "number"},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
number_field_id = number_response.json()["id"]
return project, text_field_id, number_field_id
def test_create_task_with_custom_values(self, client, db, admin_token):
def test_create_task_with_custom_values(self, client, db, auth_headers):
"""Test creating a task with custom values."""
project, text_field_id, number_field_id = self.setup_project_with_fields(
db, client, admin_token, "00000000-0000-0000-0000-000000000001"
db, client, auth_headers, "00000000-0000-0000-0000-000000000001"
)
response = client.post(
@@ -370,15 +370,15 @@ class TestCustomValuesWithTasks:
{"field_id": number_field_id, "value": "8"},
],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 201
def test_get_task_includes_custom_values(self, client, db, admin_token):
def test_get_task_includes_custom_values(self, client, db, auth_headers):
"""Test that getting a task includes custom values."""
project, text_field_id, number_field_id = self.setup_project_with_fields(
db, client, admin_token, "00000000-0000-0000-0000-000000000001"
db, client, auth_headers, "00000000-0000-0000-0000-000000000001"
)
# Create task with custom values
@@ -391,14 +391,14 @@ class TestCustomValuesWithTasks:
{"field_id": number_field_id, "value": "8"},
],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
task_id = create_response.json()["id"]
# Get task and check custom values
get_response = client.get(
f"/api/tasks/{task_id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert get_response.status_code == 200
@@ -406,10 +406,10 @@ class TestCustomValuesWithTasks:
assert data["custom_values"] is not None
assert len(data["custom_values"]) >= 2
def test_update_task_custom_values(self, client, db, admin_token):
def test_update_task_custom_values(self, client, db, auth_headers):
"""Test updating custom values on a task."""
project, text_field_id, number_field_id = self.setup_project_with_fields(
db, client, admin_token, "00000000-0000-0000-0000-000000000001"
db, client, auth_headers, "00000000-0000-0000-0000-000000000001"
)
# Create task
@@ -421,7 +421,7 @@ class TestCustomValuesWithTasks:
{"field_id": text_field_id, "value": "Sprint 5"},
],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
task_id = create_response.json()["id"]
@@ -434,7 +434,7 @@ class TestCustomValuesWithTasks:
{"field_id": number_field_id, "value": "13"},
],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert update_response.status_code == 200

View File

@@ -619,7 +619,7 @@ class TestDashboardAPI:
def test_dashboard_unauthorized(self, client, db):
"""Unauthenticated requests should fail."""
response = client.get("/api/dashboard")
assert response.status_code == 403
assert response.status_code == 401 # 401 for unauthenticated, 403 for unauthorized
def test_dashboard_with_user_tasks(self, client, db, admin_token):
"""Dashboard should reflect user's tasks correctly."""

View File

@@ -312,6 +312,13 @@ class TestConfidentialProjectUpload:
mock_redis.setex(f"session:{test_user.id}", 900, token)
return token
@pytest.fixture
def test_user_csrf_token(self, 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(self, db, test_user):
"""Create a test space."""
@@ -364,7 +371,7 @@ class TestConfidentialProjectUpload:
return task
def test_upload_confidential_project_encryption_unavailable(
self, client, test_user_token, test_task, db
self, client, test_user_token, test_user_csrf_token, test_task, db
):
"""Test that uploading to confidential project returns 400 when encryption is unavailable."""
from io import BytesIO
@@ -378,7 +385,7 @@ class TestConfidentialProjectUpload:
response = client.post(
f"/api/tasks/{test_task.id}/attachments",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
files=files,
)
@@ -387,7 +394,7 @@ class TestConfidentialProjectUpload:
assert "environment variable" in response.json()["detail"]
def test_upload_confidential_project_no_active_key(
self, client, test_user_token, test_task, db
self, client, test_user_token, test_user_csrf_token, test_task, db
):
"""Test that uploading to confidential project returns 400 when no active encryption key exists."""
from io import BytesIO
@@ -408,7 +415,7 @@ class TestConfidentialProjectUpload:
response = client.post(
f"/api/tasks/{test_task.id}/attachments",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
files=files,
)

View File

@@ -614,7 +614,7 @@ class TestHealthAPI:
def test_unauthorized_access(self, client, db):
"""Unauthenticated requests should fail."""
response = client.get("/api/projects/health/dashboard")
assert response.status_code == 403
assert response.status_code == 401 # 401 for unauthenticated, 403 for unauthorized
def test_dashboard_with_status_filter(self, client, db, admin_token):
"""Dashboard should respect status filter."""

View File

@@ -38,6 +38,14 @@ def test_user_token(client, mock_redis, test_user):
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."""
@@ -284,11 +292,11 @@ class TestReportAPI:
assert "projects" in data
assert data["summary"]["total_tasks"] == 3
def test_generate_weekly_report_api(self, client, test_user_token, test_project, test_tasks, test_statuses):
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}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
)
assert response.status_code == 200
@@ -297,7 +305,7 @@ class TestReportAPI:
assert "report_id" in data
assert "summary" in data
def test_weekly_report_subscription_toggle(self, client, test_user_token, db, test_user):
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",
@@ -308,7 +316,7 @@ class TestReportAPI:
response = client.put(
"/api/reports/weekly/subscription",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={"is_active": True},
)
assert response.status_code == 200
@@ -323,7 +331,7 @@ class TestReportAPI:
response = client.put(
"/api/reports/weekly/subscription",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={"is_active": False},
)
assert response.status_code == 200

View File

@@ -52,6 +52,14 @@ def test_user_token(client, mock_redis, test_user):
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."""
@@ -445,11 +453,11 @@ class TestDeadlineReminderLogic:
class TestScheduleTriggerAPI:
"""Tests for Schedule Trigger API endpoints."""
def test_create_cron_trigger(self, client, test_user_token, test_project):
def test_create_cron_trigger(self, client, test_user_token, test_user_csrf_token, test_project):
"""Test creating a schedule trigger with cron expression."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={
"name": "Weekly Monday Reminder",
"description": "Remind every Monday at 9am",
@@ -471,11 +479,11 @@ class TestScheduleTriggerAPI:
assert data["trigger_type"] == "schedule"
assert data["conditions"]["cron_expression"] == "0 9 * * 1"
def test_create_deadline_trigger(self, client, test_user_token, test_project):
def test_create_deadline_trigger(self, client, test_user_token, test_user_csrf_token, test_project):
"""Test creating a schedule trigger with deadline reminder."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={
"name": "Deadline Reminder",
"description": "Remind 5 days before deadline",
@@ -494,11 +502,11 @@ class TestScheduleTriggerAPI:
data = response.json()
assert data["conditions"]["deadline_reminder_days"] == 5
def test_create_schedule_trigger_invalid_cron(self, client, test_user_token, test_project):
def test_create_schedule_trigger_invalid_cron(self, client, test_user_token, test_user_csrf_token, test_project):
"""Test creating a schedule trigger with invalid cron expression."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={
"name": "Invalid Cron Trigger",
"trigger_type": "schedule",
@@ -512,11 +520,11 @@ class TestScheduleTriggerAPI:
assert response.status_code == 400
assert "Invalid cron expression" in response.json()["detail"]
def test_create_schedule_trigger_missing_condition(self, client, test_user_token, test_project):
def test_create_schedule_trigger_missing_condition(self, client, test_user_token, test_user_csrf_token, test_project):
"""Test creating a schedule trigger without cron or deadline condition."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={
"name": "Empty Schedule Trigger",
"trigger_type": "schedule",
@@ -528,11 +536,11 @@ class TestScheduleTriggerAPI:
assert response.status_code == 400
assert "require either cron_expression or deadline_reminder_days" in response.json()["detail"]
def test_update_schedule_trigger_cron(self, client, test_user_token, cron_trigger):
def test_update_schedule_trigger_cron(self, client, test_user_token, test_user_csrf_token, cron_trigger):
"""Test updating a schedule trigger's cron expression."""
response = client.put(
f"/api/triggers/{cron_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={
"conditions": {
"cron_expression": "0 10 * * *", # Changed to 10am
@@ -544,11 +552,11 @@ class TestScheduleTriggerAPI:
data = response.json()
assert data["conditions"]["cron_expression"] == "0 10 * * *"
def test_update_schedule_trigger_invalid_cron(self, client, test_user_token, cron_trigger):
def test_update_schedule_trigger_invalid_cron(self, client, test_user_token, test_user_csrf_token, cron_trigger):
"""Test updating a schedule trigger with invalid cron expression."""
response = client.put(
f"/api/triggers/{cron_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token},
json={
"conditions": {
"cron_expression": "not valid",

View File

@@ -69,6 +69,22 @@ def regular_token(client, mock_redis, test_regular_user):
return token
@pytest.fixture
def csrf_token(test_admin):
"""Generate a CSRF token for the test admin user."""
from app.core.security import generate_csrf_token
return generate_csrf_token(test_admin.id)
@pytest.fixture
def auth_headers(admin_token, csrf_token):
"""Get complete auth headers including both Authorization and CSRF token."""
return {
"Authorization": f"Bearer {admin_token}",
"X-CSRF-Token": csrf_token,
}
@pytest.fixture
def test_space(db, test_admin):
"""Create a test space."""
@@ -148,11 +164,11 @@ def test_task_with_subtask(db, test_project, test_admin, test_status, test_task)
class TestSoftDelete:
"""Tests for soft delete functionality."""
def test_delete_task_soft_deletes(self, client, admin_token, test_task, db):
def test_delete_task_soft_deletes(self, client, auth_headers, test_task, db):
"""Test that DELETE soft-deletes a task."""
response = client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
@@ -165,36 +181,36 @@ class TestSoftDelete:
assert test_task.deleted_at is not None
assert test_task.deleted_by is not None
def test_deleted_task_not_in_list(self, client, admin_token, test_project, test_task, db):
def test_deleted_task_not_in_list(self, client, auth_headers, test_project, test_task, db):
"""Test that deleted tasks are not shown in list."""
# Delete the task
client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# List tasks
response = client.get(
f"/api/projects/{test_project.id}/tasks",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
def test_admin_can_list_deleted_with_include_deleted(self, client, admin_token, test_project, test_task, db):
def test_admin_can_list_deleted_with_include_deleted(self, client, auth_headers, test_project, test_task, db):
"""Test that admin can see deleted tasks with include_deleted parameter."""
# Delete the task
client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# List with include_deleted
response = client.get(
f"/api/projects/{test_project.id}/tasks?include_deleted=true",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
@@ -202,12 +218,12 @@ class TestSoftDelete:
assert data["total"] == 1
assert data["tasks"][0]["id"] == test_task.id
def test_regular_user_cannot_see_deleted_with_include_deleted(self, client, regular_token, test_project, test_task, admin_token, db):
def test_regular_user_cannot_see_deleted_with_include_deleted(self, client, regular_token, test_project, test_task, auth_headers, db, csrf_token):
"""Test that non-admin cannot see deleted tasks even with include_deleted."""
# Delete the task as admin
client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Try to list with include_deleted as regular user
@@ -220,12 +236,12 @@ class TestSoftDelete:
data = response.json()
assert data["total"] == 0
def test_get_deleted_task_returns_404_for_regular_user(self, client, admin_token, regular_token, test_task, db):
def test_get_deleted_task_returns_404_for_regular_user(self, client, auth_headers, regular_token, test_task, db):
"""Test that getting a deleted task returns 404 for non-admin."""
# Delete the task
client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Try to get as regular user
@@ -236,28 +252,28 @@ class TestSoftDelete:
assert response.status_code == 404
def test_admin_can_view_deleted_task(self, client, admin_token, test_task, db):
def test_admin_can_view_deleted_task(self, client, auth_headers, test_task, db):
"""Test that admin can view a deleted task."""
# Delete the task
client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Get as admin
response = client.get(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
def test_cascade_soft_delete_subtasks(self, client, admin_token, test_task, test_task_with_subtask, db):
def test_cascade_soft_delete_subtasks(self, client, auth_headers, test_task, test_task_with_subtask, db):
"""Test that deleting a parent task soft-deletes its subtasks."""
# Delete the parent task
response = client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
@@ -270,18 +286,18 @@ class TestSoftDelete:
class TestRestoreTask:
"""Tests for task restoration functionality."""
def test_restore_task(self, client, admin_token, test_task, db):
def test_restore_task(self, client, auth_headers, test_task, db):
"""Test that admin can restore a deleted task."""
# Delete the task
client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Restore the task
response = client.post(
f"/api/tasks/{test_task.id}/restore",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
@@ -292,27 +308,29 @@ class TestRestoreTask:
assert test_task.deleted_at is None
assert test_task.deleted_by is None
def test_regular_user_cannot_restore(self, client, admin_token, regular_token, test_task, db):
def test_regular_user_cannot_restore(self, client, auth_headers, regular_token, test_task, db, test_regular_user):
"""Test that non-admin cannot restore a deleted task."""
from app.core.security import generate_csrf_token
# Delete the task
client.delete(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Try to restore as regular user
regular_csrf = generate_csrf_token(test_regular_user.id)
response = client.post(
f"/api/tasks/{test_task.id}/restore",
headers={"Authorization": f"Bearer {regular_token}"},
headers={"Authorization": f"Bearer {regular_token}", "X-CSRF-Token": regular_csrf},
)
assert response.status_code == 403
def test_cannot_restore_non_deleted_task(self, client, admin_token, test_task, db):
def test_cannot_restore_non_deleted_task(self, client, auth_headers, test_task, db):
"""Test that restoring a non-deleted task returns error."""
response = client.post(
f"/api/tasks/{test_task.id}/restore",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 400
@@ -322,12 +340,12 @@ class TestRestoreTask:
class TestSubtaskCount:
"""Tests for subtask count excluding deleted."""
def test_subtask_count_excludes_deleted(self, client, admin_token, test_task, test_task_with_subtask, db):
def test_subtask_count_excludes_deleted(self, client, auth_headers, test_task, test_task_with_subtask, db):
"""Test that subtask_count excludes deleted subtasks."""
# Get parent task before deletion
response = client.get(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["subtask_count"] == 1
@@ -335,13 +353,13 @@ class TestSubtaskCount:
# Delete subtask
client.delete(
f"/api/tasks/{test_task_with_subtask.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
# Get parent task after deletion
response = client.get(
f"/api/tasks/{test_task.id}",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["subtask_count"] == 0

View File

@@ -57,7 +57,7 @@ class TestSpacesAPI:
"/api/spaces",
json={"name": "Test Space", "description": "Test"}
)
assert response.status_code == 403 # No auth header
assert response.status_code == 401 # 401 for unauthenticated, 403 for unauthorized
def test_space_routes_exist(self):
"""Test that all space routes are registered."""

View File

@@ -783,7 +783,7 @@ class TestDateValidation:
class TestDependencyCRUDAPI:
"""Test dependency CRUD API endpoints."""
def test_create_dependency(self, client, db, admin_token):
def test_create_dependency(self, client, db, admin_token, csrf_token):
"""Test creating a dependency via API."""
# Create test data
space = Space(
@@ -838,7 +838,7 @@ class TestDependencyCRUDAPI:
"dependency_type": "FS",
"lag_days": 0
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 201
@@ -914,7 +914,7 @@ class TestDependencyCRUDAPI:
assert data["total"] >= 1
assert any(d["predecessor_id"] == "task-api-list-1" for d in data["dependencies"])
def test_delete_dependency(self, client, db, admin_token):
def test_delete_dependency(self, client, db, admin_token, csrf_token):
"""Test deleting a dependency."""
# Create test data
space = Space(
@@ -973,7 +973,7 @@ class TestDependencyCRUDAPI:
# Delete dependency
response = client.delete(
"/api/task-dependencies/dep-api-del",
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 204
@@ -984,7 +984,7 @@ class TestDependencyCRUDAPI:
).first()
assert dep_check is None
def test_circular_dependency_rejected_via_api(self, client, db, admin_token):
def test_circular_dependency_rejected_via_api(self, client, db, admin_token, csrf_token):
"""Test that circular dependencies are rejected via API."""
# Create test data
space = Space(
@@ -1049,7 +1049,7 @@ class TestDependencyCRUDAPI:
"dependency_type": "FS",
"lag_days": 0
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 400
@@ -1060,7 +1060,7 @@ class TestDependencyCRUDAPI:
class TestTaskDateValidationAPI:
"""Test task date validation in task API."""
def test_create_task_with_invalid_dates_rejected(self, client, db, admin_token):
def test_create_task_with_invalid_dates_rejected(self, client, db, admin_token, csrf_token):
"""Test that creating a task with start_date > due_date is rejected."""
# Create test data
space = Space(
@@ -1099,13 +1099,13 @@ class TestTaskDateValidationAPI:
"start_date": (now + timedelta(days=10)).isoformat(),
"due_date": now.isoformat(),
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 400
assert "Start date cannot be after due date" in response.json()["detail"]
def test_update_task_with_invalid_dates_rejected(self, client, db, admin_token):
def test_update_task_with_invalid_dates_rejected(self, client, db, admin_token, csrf_token):
"""Test that updating a task to have start_date > due_date is rejected."""
# Create test data
space = Space(
@@ -1153,12 +1153,12 @@ class TestTaskDateValidationAPI:
json={
"start_date": (now + timedelta(days=20)).isoformat(),
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 400
def test_create_task_with_valid_dates_accepted(self, client, db, admin_token):
def test_create_task_with_valid_dates_accepted(self, client, db, admin_token, csrf_token):
"""Test that creating a task with valid dates is accepted."""
# Create test data
space = Space(
@@ -1197,7 +1197,7 @@ class TestTaskDateValidationAPI:
"start_date": now.isoformat(),
"due_date": (now + timedelta(days=10)).isoformat(),
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 201
@@ -1217,7 +1217,7 @@ class TestDependencyTypes:
assert DependencyType.FF.value == "FF"
assert DependencyType.SF.value == "SF"
def test_create_dependency_with_different_types(self, client, db, admin_token):
def test_create_dependency_with_different_types(self, client, db, admin_token, csrf_token):
"""Test creating dependencies with different types via API."""
# Create test data
space = Space(
@@ -1268,7 +1268,7 @@ class TestDependencyTypes:
"dependency_type": dep_type,
"lag_days": i
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 201

View File

@@ -43,6 +43,22 @@ def test_user_token(client, mock_redis, test_user):
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_user_auth_headers(test_user_token, test_user_csrf_token):
"""Get complete auth headers for test user."""
return {
"Authorization": f"Bearer {test_user_token}",
"X-CSRF-Token": test_user_csrf_token,
}
@pytest.fixture
def test_space(db, test_user):
"""Create a test space."""
@@ -513,11 +529,11 @@ class TestTriggerNotifications:
class TestTriggerAPI:
"""Tests for Trigger API endpoints."""
def test_create_trigger(self, client, test_user_token, test_project, test_status):
def test_create_trigger(self, client, test_user_auth_headers, test_project, test_status):
"""Test creating a trigger."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
json={
"name": "New Trigger",
"description": "Test trigger",
@@ -563,11 +579,11 @@ class TestTriggerAPI:
assert data["id"] == test_trigger.id
assert data["name"] == test_trigger.name
def test_update_trigger(self, client, test_user_token, test_trigger):
def test_update_trigger(self, client, test_user_auth_headers, test_trigger):
"""Test updating a trigger."""
response = client.put(
f"/api/triggers/{test_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
json={
"name": "Updated Trigger",
"is_active": False,
@@ -579,11 +595,11 @@ class TestTriggerAPI:
assert data["name"] == "Updated Trigger"
assert data["is_active"] is False
def test_delete_trigger(self, client, test_user_token, test_trigger):
def test_delete_trigger(self, client, test_user_auth_headers, test_trigger, test_user_token):
"""Test deleting a trigger."""
response = client.delete(
f"/api/triggers/{test_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
)
assert response.status_code == 204
@@ -616,11 +632,11 @@ class TestTriggerAPI:
data = response.json()
assert data["total"] >= 1
def test_create_trigger_invalid_field(self, client, test_user_token, test_project):
def test_create_trigger_invalid_field(self, client, test_user_auth_headers, test_project):
"""Test creating a trigger with invalid field."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
json={
"name": "Invalid Trigger",
"trigger_type": "field_change",
@@ -636,11 +652,11 @@ class TestTriggerAPI:
assert response.status_code == 400
assert "Invalid condition field" in response.json()["detail"]
def test_create_trigger_invalid_operator(self, client, test_user_token, test_project):
def test_create_trigger_invalid_operator(self, client, test_user_auth_headers, test_project):
"""Test creating a trigger with invalid operator."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
headers=test_user_auth_headers,
json={
"name": "Invalid Trigger",
"trigger_type": "field_change",

View File

@@ -1,6 +1,7 @@
import pytest
from app.models.user import User
from app.models.department import Department
from app.core.security import generate_csrf_token
class TestUserEndpoints:
@@ -35,7 +36,7 @@ class TestUserEndpoints:
)
assert response.status_code == 404
def test_update_user(self, client, admin_token, db):
def test_update_user(self, client, auth_headers, db):
"""Test updating a user."""
# Create a test user
test_user = User(
@@ -49,7 +50,7 @@ class TestUserEndpoints:
response = client.patch(
"/api/users/test-user-001",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"name": "Updated Name"},
)
assert response.status_code == 200
@@ -84,9 +85,10 @@ class TestUserEndpoints:
mock_redis.setex("session:non-admin-001", 900, token)
# Try to modify system admin - should fail with 403
csrf_token = generate_csrf_token("non-admin-001")
response = client.patch(
"/api/users/00000000-0000-0000-0000-000000000001",
headers={"Authorization": f"Bearer {token}"},
headers={"Authorization": f"Bearer {token}", "X-CSRF-Token": csrf_token},
json={"name": "Hacked Name"},
)
# Engineer role doesn't have users.write permission
@@ -123,16 +125,17 @@ class TestCapacityUpdate:
mock_redis.setex("session:capacity-user-001", 900, token)
# Update own capacity
csrf_token = generate_csrf_token("capacity-user-001")
response = client.put(
"/api/users/capacity-user-001/capacity",
headers={"Authorization": f"Bearer {token}"},
headers={"Authorization": f"Bearer {token}", "X-CSRF-Token": csrf_token},
json={"capacity_hours": 35.5},
)
assert response.status_code == 200
data = response.json()
assert float(data["capacity"]) == 35.5
def test_admin_can_update_other_user_capacity(self, client, admin_token, db):
def test_admin_can_update_other_user_capacity(self, client, auth_headers, db):
"""Test that admin can update another user's capacity."""
# Create a test user
test_user = User(
@@ -148,7 +151,7 @@ class TestCapacityUpdate:
# Admin updates another user's capacity
response = client.put(
"/api/users/capacity-user-002/capacity",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"capacity_hours": 20.0},
)
assert response.status_code == 200
@@ -189,15 +192,16 @@ class TestCapacityUpdate:
mock_redis.setex("session:capacity-user-003", 900, token)
# User1 tries to update user2's capacity - should fail
csrf_token = generate_csrf_token("capacity-user-003")
response = client.put(
"/api/users/capacity-user-004/capacity",
headers={"Authorization": f"Bearer {token}"},
headers={"Authorization": f"Bearer {token}", "X-CSRF-Token": csrf_token},
json={"capacity_hours": 30.0},
)
assert response.status_code == 403
assert "Only admin, manager, or the user themselves" in response.json()["detail"]
def test_update_capacity_invalid_value_negative(self, client, admin_token, db):
def test_update_capacity_invalid_value_negative(self, client, auth_headers, db):
"""Test that negative capacity hours are rejected."""
# Create a test user
test_user = User(
@@ -212,7 +216,7 @@ class TestCapacityUpdate:
response = client.put(
"/api/users/capacity-user-005/capacity",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"capacity_hours": -5.0},
)
# Pydantic validation returns 422 Unprocessable Entity
@@ -221,7 +225,7 @@ class TestCapacityUpdate:
# Check validation error message in Pydantic format
assert any("non-negative" in str(err).lower() for err in error_detail)
def test_update_capacity_invalid_value_too_high(self, client, admin_token, db):
def test_update_capacity_invalid_value_too_high(self, client, auth_headers, db):
"""Test that capacity hours exceeding 168 are rejected."""
# Create a test user
test_user = User(
@@ -236,7 +240,7 @@ class TestCapacityUpdate:
response = client.put(
"/api/users/capacity-user-006/capacity",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"capacity_hours": 200.0},
)
# Pydantic validation returns 422 Unprocessable Entity
@@ -245,11 +249,11 @@ class TestCapacityUpdate:
# Check validation error message in Pydantic format
assert any("168" in str(err) for err in error_detail)
def test_update_capacity_nonexistent_user(self, client, admin_token):
def test_update_capacity_nonexistent_user(self, client, auth_headers):
"""Test updating capacity for a nonexistent user."""
response = client.put(
"/api/users/nonexistent-user-id/capacity",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"capacity_hours": 40.0},
)
assert response.status_code == 404
@@ -303,16 +307,17 @@ class TestCapacityUpdate:
mock_redis.setex("session:manager-cap-001", 900, token)
# Manager updates regular user's capacity
csrf_token = generate_csrf_token("manager-cap-001")
response = client.put(
"/api/users/regular-cap-001/capacity",
headers={"Authorization": f"Bearer {token}"},
headers={"Authorization": f"Bearer {token}", "X-CSRF-Token": csrf_token},
json={"capacity_hours": 30.0},
)
assert response.status_code == 200
data = response.json()
assert float(data["capacity"]) == 30.0
def test_capacity_change_creates_audit_log(self, client, admin_token, db):
def test_capacity_change_creates_audit_log(self, client, auth_headers, db):
"""Test that capacity changes are recorded in audit trail."""
from app.models import AuditLog
@@ -330,7 +335,7 @@ class TestCapacityUpdate:
# Update capacity
response = client.put(
"/api/users/capacity-audit-001/capacity",
headers={"Authorization": f"Bearer {admin_token}"},
headers=auth_headers,
json={"capacity_hours": 35.0},
)
assert response.status_code == 200

View File

@@ -449,7 +449,7 @@ class TestWorkloadAPI:
def test_unauthorized_access(self, client, db):
"""Unauthenticated requests should fail."""
response = client.get("/api/workload/heatmap")
assert response.status_code == 403 # No auth header
assert response.status_code == 401 # 401 for unauthenticated, 403 for unauthorized
class TestWorkloadAccessControl: