- Added tests for OAuth provider admin and consent endpoints covering edge cases. - Extended agent-related tests to handle incorrect project associations and lifecycle state transitions. - Introduced tests for sprint status transitions and validation checks. - Improved multiline formatting consistency across all test functions.
1096 lines
40 KiB
Python
1096 lines
40 KiB
Python
# tests/api/routes/syndarix/test_edge_cases.py
|
|
"""
|
|
Edge case and bug-hunting tests for Syndarix API.
|
|
|
|
These tests focus on potential production issues:
|
|
- Assigning issues to terminated agents
|
|
- Sprint lifecycle edge cases
|
|
- Project archiving with active resources
|
|
- State consistency across operations
|
|
"""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from starlette import status
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_project(client, user_token):
|
|
"""Create a test project."""
|
|
response = await client.post(
|
|
"/api/v1/projects",
|
|
json={
|
|
"name": f"Edge Case Test Project {uuid.uuid4().hex[:6]}",
|
|
"slug": f"edge-case-project-{uuid.uuid4().hex[:8]}",
|
|
"autonomy_level": "milestone",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
return response.json()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_agent_type(client, superuser_token):
|
|
"""Create a test agent type."""
|
|
response = await client.post(
|
|
"/api/v1/agent-types",
|
|
json={
|
|
"name": f"Edge Case Agent Type {uuid.uuid4().hex[:6]}",
|
|
"slug": f"edge-case-type-{uuid.uuid4().hex[:8]}",
|
|
"expertise": ["testing"],
|
|
"primary_model": "claude-3-opus",
|
|
"personality_prompt": "A test agent for edge cases.",
|
|
},
|
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
return response.json()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_agent(client, user_token, test_project, test_agent_type):
|
|
"""Create a test agent in the project."""
|
|
response = await client.post(
|
|
f"/api/v1/projects/{test_project['id']}/agents",
|
|
json={
|
|
"project_id": test_project["id"],
|
|
"agent_type_id": test_agent_type["id"],
|
|
"name": f"Test Agent {uuid.uuid4().hex[:6]}",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
return response.json()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def terminated_agent(client, user_token, test_project, test_agent):
|
|
"""Create and terminate an agent."""
|
|
# Terminate the agent using DELETE endpoint
|
|
response = await client.delete(
|
|
f"/api/v1/projects/{test_project['id']}/agents/{test_agent['id']}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK, (
|
|
f"Failed to terminate: {response.json()}"
|
|
)
|
|
|
|
# Return agent info with terminated status
|
|
return {**test_agent, "status": "terminated"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestAssignIssueToTerminatedAgent:
|
|
"""
|
|
Tests for assigning issues to terminated agents.
|
|
|
|
POTENTIAL BUG: The system might allow assigning issues to terminated agents,
|
|
which doesn't make sense since terminated agents cannot work on issues.
|
|
"""
|
|
|
|
async def test_assign_issue_to_terminated_agent_should_fail(
|
|
self, client, user_token, test_project, terminated_agent
|
|
):
|
|
"""
|
|
Assigning an issue to a terminated agent should be rejected.
|
|
|
|
Expected behavior: Return 422 validation error
|
|
"""
|
|
project_id = test_project["id"]
|
|
agent_id = terminated_agent["id"]
|
|
|
|
# Create an issue first
|
|
issue_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Test Issue for Terminated Agent",
|
|
"body": "This issue should not be assignable to a terminated agent",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert issue_response.status_code == status.HTTP_201_CREATED
|
|
issue = issue_response.json()
|
|
|
|
# Try to assign the issue to the terminated agent - should fail
|
|
assign_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{issue['id']}/assign",
|
|
json={"assigned_agent_id": agent_id},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Should fail with validation error
|
|
assert assign_response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
errors = assign_response.json()["errors"]
|
|
assert any("terminated" in err["message"].lower() for err in errors)
|
|
|
|
async def test_create_issue_with_terminated_agent_assignment(
|
|
self, client, user_token, test_project, terminated_agent
|
|
):
|
|
"""Creating an issue pre-assigned to a terminated agent should fail."""
|
|
project_id = test_project["id"]
|
|
agent_id = terminated_agent["id"]
|
|
|
|
# Try to create an issue with terminated agent assignment
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue Pre-Assigned to Terminated Agent",
|
|
"body": "This should fail",
|
|
"assigned_agent_id": agent_id,
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Should fail with validation error
|
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
errors = response.json()["errors"]
|
|
assert any("terminated" in err["message"].lower() for err in errors)
|
|
|
|
async def test_update_issue_to_terminated_agent(
|
|
self, client, user_token, test_project, terminated_agent
|
|
):
|
|
"""Updating an issue to assign it to a terminated agent should fail."""
|
|
project_id = test_project["id"]
|
|
agent_id = terminated_agent["id"]
|
|
|
|
# Create an issue
|
|
issue_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue to be Updated",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert issue_response.status_code == status.HTTP_201_CREATED
|
|
issue = issue_response.json()
|
|
|
|
# Try to update with terminated agent assignment - should fail
|
|
update_response = await client.patch(
|
|
f"/api/v1/projects/{project_id}/issues/{issue['id']}",
|
|
json={"assigned_agent_id": agent_id},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Should fail with validation error
|
|
assert update_response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
errors = update_response.json()["errors"]
|
|
assert any("terminated" in err["message"].lower() for err in errors)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTerminateAgentWithAssignedIssues:
|
|
"""Tests for terminating agents that have assigned issues."""
|
|
|
|
async def test_terminate_agent_with_assigned_issues(
|
|
self, client, user_token, test_project, test_agent
|
|
):
|
|
"""
|
|
When an agent is terminated, its assigned issues should be auto-unassigned.
|
|
|
|
This prevents orphaned issue assignments to non-functional agents.
|
|
"""
|
|
project_id = test_project["id"]
|
|
agent_id = test_agent["id"]
|
|
|
|
# Create and assign an issue to the agent
|
|
issue_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue Assigned to Agent",
|
|
"assigned_agent_id": agent_id,
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert issue_response.status_code == status.HTTP_201_CREATED
|
|
issue = issue_response.json()
|
|
assert issue["assigned_agent_id"] == agent_id
|
|
|
|
# Terminate the agent
|
|
terminate_response = await client.delete(
|
|
f"/api/v1/projects/{test_project['id']}/agents/{agent_id}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert terminate_response.status_code == status.HTTP_200_OK
|
|
|
|
# Verify the issue was auto-unassigned
|
|
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()
|
|
|
|
# Issue should be unassigned now
|
|
assert updated_issue.get("assigned_agent_id") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSprintLifecycleEdgeCases:
|
|
"""
|
|
Tests for sprint lifecycle edge cases.
|
|
"""
|
|
|
|
async def test_start_sprint_twice(self, client, user_token, test_project):
|
|
"""
|
|
Test that starting an already active sprint fails gracefully.
|
|
"""
|
|
from datetime import date, timedelta
|
|
|
|
project_id = test_project["id"]
|
|
|
|
# Create a sprint
|
|
sprint_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints",
|
|
json={
|
|
"project_id": project_id,
|
|
"name": "Sprint to Start Twice",
|
|
"number": 1,
|
|
"start_date": str(date.today()),
|
|
"end_date": str(date.today() + timedelta(days=14)),
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert sprint_response.status_code == status.HTTP_201_CREATED
|
|
sprint = sprint_response.json()
|
|
|
|
# Start the sprint
|
|
start_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/start",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert start_response.status_code == status.HTTP_200_OK
|
|
|
|
# Try to start it again
|
|
second_start = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/start",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Should fail with appropriate error
|
|
assert second_start.status_code in [
|
|
status.HTTP_400_BAD_REQUEST,
|
|
status.HTTP_409_CONFLICT,
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
], f"Expected error for double-start, got: {second_start.status_code}"
|
|
|
|
async def test_complete_planned_sprint(self, client, user_token, test_project):
|
|
"""
|
|
Test that completing a PLANNED (not started) sprint fails.
|
|
"""
|
|
from datetime import date, timedelta
|
|
|
|
project_id = test_project["id"]
|
|
|
|
# Create a sprint (status: PLANNED)
|
|
sprint_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints",
|
|
json={
|
|
"project_id": project_id,
|
|
"name": "Sprint Never Started",
|
|
"number": 99,
|
|
"start_date": str(date.today()),
|
|
"end_date": str(date.today() + timedelta(days=14)),
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert sprint_response.status_code == status.HTTP_201_CREATED
|
|
sprint = sprint_response.json()
|
|
|
|
# Try to complete without starting
|
|
complete_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/complete",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Should fail - can't complete a sprint that was never started
|
|
assert complete_response.status_code in [
|
|
status.HTTP_400_BAD_REQUEST,
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
], f"Should not complete PLANNED sprint, got: {complete_response.status_code}"
|
|
|
|
async def test_reopen_cancelled_sprint(self, client, user_token, test_project):
|
|
"""
|
|
Test that cancelled sprints cannot be restarted.
|
|
"""
|
|
from datetime import date, timedelta
|
|
|
|
project_id = test_project["id"]
|
|
|
|
# Create and cancel a sprint
|
|
sprint_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints",
|
|
json={
|
|
"project_id": project_id,
|
|
"name": "Sprint to Cancel",
|
|
"number": 100,
|
|
"start_date": str(date.today()),
|
|
"end_date": str(date.today() + timedelta(days=14)),
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert sprint_response.status_code == status.HTTP_201_CREATED
|
|
sprint = sprint_response.json()
|
|
|
|
# Cancel the sprint
|
|
cancel_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/cancel",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert cancel_response.status_code == status.HTTP_200_OK
|
|
|
|
# Try to start the cancelled sprint
|
|
start_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/start",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Should fail - cancelled sprints are terminal
|
|
assert start_response.status_code in [
|
|
status.HTTP_400_BAD_REQUEST,
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
], f"Should not start cancelled sprint, got: {start_response.status_code}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestProjectArchivingEdgeCases:
|
|
"""
|
|
Tests for project archiving with active resources.
|
|
|
|
QUESTION: What should happen to active agents/sprints when archiving?
|
|
"""
|
|
|
|
async def test_archive_project_with_active_sprint(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""
|
|
Test archiving a project that has an active sprint.
|
|
|
|
Should this:
|
|
A) Fail (require cancelling sprint first)?
|
|
B) Auto-cancel the sprint?
|
|
C) Archive with active sprint (inconsistent state)?
|
|
"""
|
|
from datetime import date, timedelta
|
|
|
|
project_id = test_project["id"]
|
|
|
|
# Create and start a sprint
|
|
sprint_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints",
|
|
json={
|
|
"project_id": project_id,
|
|
"name": "Active Sprint",
|
|
"number": 1,
|
|
"start_date": str(date.today()),
|
|
"end_date": str(date.today() + timedelta(days=14)),
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert sprint_response.status_code == status.HTTP_201_CREATED
|
|
sprint = sprint_response.json()
|
|
|
|
# Start the sprint
|
|
await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}/start",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Try to archive the project (DELETE endpoint)
|
|
archive_response = await client.delete(
|
|
f"/api/v1/projects/{project_id}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Document the behavior
|
|
if archive_response.status_code == status.HTTP_200_OK:
|
|
# Project was archived - check sprint status
|
|
sprint_check = await client.get(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprint['id']}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
if sprint_check.status_code == status.HTTP_200_OK:
|
|
sprint_data = sprint_check.json()
|
|
if sprint_data.get("status") == "active":
|
|
pytest.fail(
|
|
"DESIGN ISSUE: Project archived with active sprint. "
|
|
"Sprint should be cancelled or archiving should fail."
|
|
)
|
|
|
|
async def test_archive_project_with_working_agent(
|
|
self, client, user_token, test_project, test_agent
|
|
):
|
|
"""
|
|
Test archiving a project that has working agents.
|
|
"""
|
|
project_id = test_project["id"]
|
|
agent_id = test_agent["id"]
|
|
|
|
# Set agent to working status
|
|
await client.patch(
|
|
f"/api/v1/projects/{project_id}/agents/{agent_id}/status",
|
|
json={"status": "working", "current_task": "Processing something"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
# The status update might fail if not allowed, which is fine
|
|
|
|
# Try to archive (DELETE endpoint)
|
|
archive_response = await client.delete(
|
|
f"/api/v1/projects/{project_id}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
if archive_response.status_code == status.HTTP_200_OK:
|
|
# Check if agents were terminated
|
|
agent_check = await client.get(
|
|
f"/api/v1/projects/{project_id}/agents/{agent_id}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
if agent_check.status_code == status.HTTP_200_OK:
|
|
agent_data = agent_check.json()
|
|
if agent_data.get("status") != "terminated":
|
|
pytest.fail(
|
|
f"DESIGN ISSUE: Project archived but agent status is "
|
|
f"'{agent_data.get('status')}', not 'terminated'"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestConcurrencyEdgeCases:
|
|
"""
|
|
Tests for potential race conditions and concurrency issues.
|
|
"""
|
|
|
|
async def test_start_two_sprints_simultaneously(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""
|
|
Test that only one sprint can be active at a time.
|
|
|
|
If two requests try to start sprints simultaneously, only one should succeed.
|
|
"""
|
|
from datetime import date, timedelta
|
|
|
|
project_id = test_project["id"]
|
|
|
|
# Create two sprints
|
|
sprints = []
|
|
for i in range(2):
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints",
|
|
json={
|
|
"project_id": project_id,
|
|
"name": f"Concurrent Sprint {i}",
|
|
"number": 200 + i,
|
|
"start_date": str(date.today()),
|
|
"end_date": str(date.today() + timedelta(days=14)),
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
sprints.append(response.json())
|
|
|
|
# Try to start both sprints (sequentially in this test, but simulating the check)
|
|
start1 = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprints[0]['id']}/start",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
start2 = await client.post(
|
|
f"/api/v1/projects/{project_id}/sprints/{sprints[1]['id']}/start",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Exactly one should succeed
|
|
successes = sum(
|
|
1 for r in [start1, start2] if r.status_code == status.HTTP_200_OK
|
|
)
|
|
failures = sum(1 for r in [start1, start2] if r.status_code in [400, 409, 422])
|
|
|
|
assert successes == 1, f"Expected exactly 1 success, got {successes}"
|
|
assert failures == 1, f"Expected exactly 1 failure, got {failures}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestDataIntegrityEdgeCases:
|
|
"""
|
|
Tests for data integrity and referential consistency.
|
|
"""
|
|
|
|
async def test_delete_agent_type_with_active_instances(
|
|
self, client, superuser_token, user_token, test_project, test_agent_type
|
|
):
|
|
"""
|
|
Test deleting an agent type that has active instances.
|
|
|
|
Should fail or cascade appropriately.
|
|
"""
|
|
project_id = test_project["id"]
|
|
agent_type_id = test_agent_type["id"]
|
|
|
|
# Create an agent from this type
|
|
agent_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/agents",
|
|
json={
|
|
"project_id": project_id,
|
|
"agent_type_id": agent_type_id,
|
|
"name": "Agent from Type",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert agent_response.status_code == status.HTTP_201_CREATED
|
|
|
|
# Try to delete the agent type
|
|
delete_response = await client.delete(
|
|
f"/api/v1/agent-types/{agent_type_id}",
|
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
|
)
|
|
|
|
# Should fail or handle gracefully
|
|
if delete_response.status_code == status.HTTP_200_OK:
|
|
# If deletion succeeded, verify instances are handled
|
|
# This could indicate a cascade delete or orphaned instances
|
|
pass
|
|
else:
|
|
# Expected - should reject deletion with active instances
|
|
assert delete_response.status_code in [
|
|
status.HTTP_400_BAD_REQUEST,
|
|
status.HTTP_409_CONFLICT,
|
|
]
|
|
|
|
async def test_move_issue_to_nonexistent_sprint(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""
|
|
Test assigning an issue to a sprint ID that doesn't exist.
|
|
"""
|
|
project_id = test_project["id"]
|
|
fake_sprint_id = str(uuid.uuid4())
|
|
|
|
# Create an issue
|
|
issue_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue for Fake Sprint",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert issue_response.status_code == status.HTTP_201_CREATED
|
|
issue = issue_response.json()
|
|
|
|
# Try to assign to nonexistent sprint
|
|
update_response = await client.patch(
|
|
f"/api/v1/projects/{project_id}/issues/{issue['id']}",
|
|
json={"sprint_id": fake_sprint_id},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert update_response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_assign_issue_to_other_projects_sprint(self, client, user_token):
|
|
"""
|
|
IDOR Test: Try to assign an issue to a sprint from a different project.
|
|
"""
|
|
# Create two projects
|
|
project1 = await client.post(
|
|
"/api/v1/projects",
|
|
json={
|
|
"name": f"Project 1 {uuid.uuid4().hex[:6]}",
|
|
"slug": f"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"Project 2 {uuid.uuid4().hex[:6]}",
|
|
"slug": f"project-2-{uuid.uuid4().hex[:8]}",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert project2.status_code == status.HTTP_201_CREATED
|
|
p2 = project2.json()
|
|
|
|
# Create a sprint in project 2
|
|
from datetime import date, timedelta
|
|
|
|
sprint_response = await client.post(
|
|
f"/api/v1/projects/{p2['id']}/sprints",
|
|
json={
|
|
"project_id": p2["id"],
|
|
"name": "Sprint in Project 2",
|
|
"number": 1,
|
|
"start_date": str(date.today()),
|
|
"end_date": str(date.today() + timedelta(days=14)),
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
assert sprint_response.status_code == status.HTTP_201_CREATED
|
|
sprint = sprint_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 sprint
|
|
update_response = await client.patch(
|
|
f"/api/v1/projects/{p1['id']}/issues/{issue['id']}",
|
|
json={"sprint_id": sprint["id"]},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# This MUST fail - sprint 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 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(
|
|
"BUG: Sprint status is still 'active' after project archive. "
|
|
"Expected 'cancelled'. Archive should cancel active sprints."
|
|
)
|