fix(backend): critical bug fixes for agent termination and sprint validation

Bug Fixes:
- bulk_terminate_by_project now unassigns issues before terminating agents
  to prevent orphaned issue assignments
- PATCH /issues/{id} now validates sprint status - cannot assign issues
  to COMPLETED or CANCELLED sprints
- archive_project now performs cascading cleanup:
  - Terminates all active agent instances
  - Cancels all planned/active sprints
  - Unassigns issues from terminated agents

Added edge case tests for all fixed bugs (19 new tests total):
- TestBulkTerminateEdgeCases
- TestSprintStatusValidation
- TestArchiveProjectCleanup
- TestDataIntegrityEdgeCases (IDOR protection)

Coverage: 93% (1836 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 15:23:21 +01:00
parent b8265783f3
commit 06b2491c1f
4 changed files with 532 additions and 12 deletions

View File

@@ -400,9 +400,9 @@ class TestProjectArchivingEdgeCases:
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to archive the project
archive_response = await client.post(
f"/api/v1/projects/{project_id}/archive",
# Try to archive the project (DELETE endpoint)
archive_response = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
@@ -439,9 +439,9 @@ class TestProjectArchivingEdgeCases:
)
# The status update might fail if not allowed, which is fine
# Try to archive
archive_response = await client.post(
f"/api/v1/projects/{project_id}/archive",
# Try to archive (DELETE endpoint)
archive_response = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
@@ -663,3 +663,427 @@ class TestDataIntegrityEdgeCases:
status.HTTP_404_NOT_FOUND,
status.HTTP_422_UNPROCESSABLE_ENTITY,
], f"IDOR BUG: Assigned issue to another project's sprint! Status: {update_response.status_code}"
async def test_assign_issue_to_other_projects_agent(
self, client, user_token, superuser_token
):
"""
IDOR Test: Try to assign an issue to an agent from a different project.
"""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={
"name": f"IDOR Project 1 {uuid.uuid4().hex[:6]}",
"slug": f"idor-project-1-{uuid.uuid4().hex[:8]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert project1.status_code == status.HTTP_201_CREATED
p1 = project1.json()
project2 = await client.post(
"/api/v1/projects",
json={
"name": f"IDOR Project 2 {uuid.uuid4().hex[:6]}",
"slug": f"idor-project-2-{uuid.uuid4().hex[:8]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert project2.status_code == status.HTTP_201_CREATED
p2 = project2.json()
# Create an agent type
agent_type_resp = await client.post(
"/api/v1/agent-types",
json={
"name": f"IDOR Test Agent {uuid.uuid4().hex[:6]}",
"slug": f"idor-test-agent-{uuid.uuid4().hex[:8]}",
"primary_model": "claude-3-opus",
"personality_prompt": "Test agent.",
},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert agent_type_resp.status_code == status.HTTP_201_CREATED
agent_type = agent_type_resp.json()
# Create an agent in project 2
agent_response = await client.post(
f"/api/v1/projects/{p2['id']}/agents",
json={
"project_id": p2["id"],
"agent_type_id": agent_type["id"],
"name": "Agent in Project 2",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert agent_response.status_code == status.HTTP_201_CREATED
agent = agent_response.json()
# Create an issue in project 1
issue_response = await client.post(
f"/api/v1/projects/{p1['id']}/issues",
json={
"project_id": p1["id"],
"title": "Issue in Project 1",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert issue_response.status_code == status.HTTP_201_CREATED
issue = issue_response.json()
# IDOR: Try to assign project 1's issue to project 2's agent
update_response = await client.patch(
f"/api/v1/projects/{p1['id']}/issues/{issue['id']}",
json={"assigned_agent_id": agent["id"]},
headers={"Authorization": f"Bearer {user_token}"},
)
# This MUST fail - agent doesn't belong to this project
assert update_response.status_code in [
status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND,
status.HTTP_422_UNPROCESSABLE_ENTITY,
], f"IDOR BUG: Assigned issue to another project's agent! Status: {update_response.status_code}"
@pytest.mark.asyncio
class TestBulkTerminateEdgeCases:
"""
Tests for bulk terminate operations and their cleanup behavior.
CRITICAL BUG FOUND: bulk_terminate_by_project does NOT unassign issues
before terminating agents, unlike the single terminate() method.
"""
async def test_bulk_terminate_should_unassign_issues(
self, client, user_token, test_project, test_agent_type
):
"""
CRITICAL BUG TEST: When bulk terminating agents, all their issues
should be automatically unassigned to prevent orphaned assignments.
The single terminate() method does this correctly, but bulk doesn't.
"""
project_id = test_project["id"]
# Create multiple agents
agents = []
for i in range(3):
agent_resp = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": test_agent_type["id"],
"name": f"Bulk Terminate Agent {i}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert agent_resp.status_code == status.HTTP_201_CREATED
agents.append(agent_resp.json())
# Assign issues to each agent
issues = []
for agent in agents:
issue_resp = await client.post(
f"/api/v1/projects/{project_id}/issues",
json={
"project_id": project_id,
"title": f"Issue for {agent['name']}",
"assigned_agent_id": agent["id"],
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert issue_resp.status_code == status.HTTP_201_CREATED
issue = issue_resp.json()
assert issue["assigned_agent_id"] == agent["id"]
issues.append(issue)
# Bulk terminate all agents via project archive/cleanup
# Note: There's no direct bulk terminate API, so we test via archive
# Archive endpoint is DELETE /projects/{id}
archive_resp = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert archive_resp.status_code == status.HTTP_200_OK
# Verify all issues are unassigned
for issue in issues:
issue_check = await client.get(
f"/api/v1/projects/{project_id}/issues/{issue['id']}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert issue_check.status_code == status.HTTP_200_OK
updated_issue = issue_check.json()
# BUG CHECK: Issues should be unassigned after bulk termination
if updated_issue.get("assigned_agent_id") is not None:
pytest.fail(
f"BUG: Issue '{updated_issue['title']}' still assigned to "
f"agent {updated_issue['assigned_agent_id']} after bulk termination. "
f"Expected assigned_agent_id=None"
)
@pytest.mark.asyncio
class TestSprintStatusValidation:
"""
Tests for sprint status validation during issue operations.
BUG FOUND: The PATCH /issues/{id} endpoint doesn't validate sprint status,
allowing issues to be assigned to COMPLETED sprints. The dedicated
/sprints/{id}/issues endpoint correctly blocks this.
"""
async def test_cannot_assign_issue_to_completed_sprint_via_patch(
self, client, user_token, test_project
):
"""
BUG TEST: Updating an issue to assign it to a COMPLETED sprint via PATCH
should fail, but currently it doesn't validate sprint status.
"""
from datetime import date, timedelta
project_id = test_project["id"]
# Create and complete a sprint
sprint_resp = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Complete",
"number": 500,
"start_date": str(date.today() - timedelta(days=14)),
"end_date": str(date.today()),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert sprint_resp.status_code == status.HTTP_201_CREATED
sprint = sprint_resp.json()
# Start the sprint
start_resp = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert start_resp.status_code == status.HTTP_200_OK
# Complete the sprint
complete_resp = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert complete_resp.status_code == status.HTTP_200_OK
# Verify sprint is completed
sprint_check = await client.get(
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert sprint_check.json()["status"] == "completed"
# Create an issue
issue_resp = await client.post(
f"/api/v1/projects/{project_id}/issues",
json={
"project_id": project_id,
"title": "Issue to Assign to Completed Sprint",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert issue_resp.status_code == status.HTTP_201_CREATED
issue = issue_resp.json()
# Try to assign issue to completed sprint via PATCH
update_resp = await client.patch(
f"/api/v1/projects/{project_id}/issues/{issue['id']}",
json={"sprint_id": sprint["id"]},
headers={"Authorization": f"Bearer {user_token}"},
)
# This SHOULD fail - cannot add issues to completed sprints
if update_resp.status_code == status.HTTP_200_OK:
pytest.fail(
"BUG: PATCH allowed assigning issue to COMPLETED sprint! "
"Status should be COMPLETED but assignment was allowed. "
"The dedicated /sprints/{id}/issues endpoint correctly blocks this, "
"but PATCH /issues/{id} does not validate sprint status."
)
else:
# Expected behavior - should reject with 400 or 422
assert update_resp.status_code in [
status.HTTP_400_BAD_REQUEST,
status.HTTP_422_UNPROCESSABLE_ENTITY,
]
async def test_cannot_assign_issue_to_cancelled_sprint_via_patch(
self, client, user_token, test_project
):
"""
BUG TEST: Updating an issue to assign it to a CANCELLED sprint should fail.
"""
from datetime import date, timedelta
project_id = test_project["id"]
# Create and cancel a sprint
sprint_resp = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Cancel",
"number": 501,
"start_date": str(date.today()),
"end_date": str(date.today() + timedelta(days=14)),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert sprint_resp.status_code == status.HTTP_201_CREATED
sprint = sprint_resp.json()
# Cancel the sprint
cancel_resp = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert cancel_resp.status_code == status.HTTP_200_OK
# Create an issue
issue_resp = await client.post(
f"/api/v1/projects/{project_id}/issues",
json={
"project_id": project_id,
"title": "Issue to Assign to Cancelled Sprint",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert issue_resp.status_code == status.HTTP_201_CREATED
issue = issue_resp.json()
# Try to assign issue to cancelled sprint via PATCH
update_resp = await client.patch(
f"/api/v1/projects/{project_id}/issues/{issue['id']}",
json={"sprint_id": sprint["id"]},
headers={"Authorization": f"Bearer {user_token}"},
)
# This SHOULD fail - cannot add issues to cancelled sprints
if update_resp.status_code == status.HTTP_200_OK:
pytest.fail(
"BUG: PATCH allowed assigning issue to CANCELLED sprint! "
"Sprint status is CANCELLED but assignment was allowed."
)
else:
assert update_resp.status_code in [
status.HTTP_400_BAD_REQUEST,
status.HTTP_422_UNPROCESSABLE_ENTITY,
]
@pytest.mark.asyncio
class TestArchiveProjectCleanup:
"""
Tests for project archiving and its cleanup behavior.
BUG FOUND: archive_project() sets status to ARCHIVED but does NOT:
1. Terminate running agents
2. Cancel active sprints
3. Unassign issues from terminated agents
"""
async def test_archive_project_should_terminate_agents(
self, client, user_token, test_project, test_agent
):
"""
BUG TEST: When archiving a project, all agents should be terminated.
Currently the archive_project() method only changes project status
without cleaning up child entities.
"""
project_id = test_project["id"]
agent_id = test_agent["id"]
# Verify agent is not terminated
agent_check = await client.get(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert agent_check.status_code == status.HTTP_200_OK
assert agent_check.json()["status"] != "terminated"
# Archive the project (DELETE endpoint)
archive_resp = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert archive_resp.status_code == status.HTTP_200_OK
# Check if agent was terminated
agent_after = await client.get(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert agent_after.status_code == status.HTTP_200_OK
agent_data = agent_after.json()
# BUG CHECK: Agent should be terminated after project archive
if agent_data.get("status") != "terminated":
pytest.fail(
f"BUG: Agent status is '{agent_data.get('status')}' after project archive. "
f"Expected 'terminated'. Archive should cascade to terminate agents."
)
async def test_archive_project_should_cancel_active_sprint(
self, client, user_token, test_project
):
"""
BUG TEST: When archiving a project, active sprints should be cancelled.
"""
from datetime import date, timedelta
project_id = test_project["id"]
# Create and start a sprint
sprint_resp = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Active Sprint for Archive Test",
"number": 600,
"start_date": str(date.today()),
"end_date": str(date.today() + timedelta(days=14)),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert sprint_resp.status_code == status.HTTP_201_CREATED
sprint = sprint_resp.json()
# Start the sprint
start_resp = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert start_resp.status_code == status.HTTP_200_OK
# Archive the project (DELETE endpoint)
archive_resp = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert archive_resp.status_code == status.HTTP_200_OK
# Check if sprint was cancelled
sprint_after = await client.get(
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert sprint_after.status_code == status.HTTP_200_OK
sprint_data = sprint_after.json()
# BUG CHECK: Sprint should be cancelled after project archive
if sprint_data.get("status") == "active":
pytest.fail(
f"BUG: Sprint status is still 'active' after project archive. "
f"Expected 'cancelled'. Archive should cancel active sprints."
)