test(syndarix): add agent_types and enhance issues API tests
- Add comprehensive test_agent_types.py (36 tests) - CRUD operations (create, read, update, deactivate) - Authorization (superuser vs regular user) - Pagination and filtering - Slug lookup functionality - Model configuration validation - Enhance test_issues.py (15 new tests, total 39) - Issue assignment/unassignment endpoints - Issue sync endpoint - Cross-project validation (IDOR prevention) - Validation error handling - Sprint/agent reference validation Coverage improvements: - agent_types.py: 41% → 83% - issues.py: 55% → 75% - Overall: 88% → 89% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -613,3 +613,373 @@ class TestIssueAuthorization:
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestIssueAssignment:
|
||||
"""Tests for issue assignment endpoints."""
|
||||
|
||||
async def test_assign_issue_to_human(self, client, user_token, test_project):
|
||||
"""Test assigning an issue to a human."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue to Assign",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Assign to human
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
||||
json={"human_assignee": "john.doe@example.com"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["human_assignee"] == "john.doe@example.com"
|
||||
|
||||
async def test_unassign_issue(self, client, user_token, test_project):
|
||||
"""Test unassigning an issue."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue and assign
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue to Unassign",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
||||
json={"human_assignee": "john.doe@example.com"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Unassign
|
||||
response = await client.delete(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}/assignment",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
# After unassign, assigned_agent_id should be None
|
||||
# Note: human_assignee may or may not be cleared depending on implementation
|
||||
assert data["assigned_agent_id"] is None
|
||||
|
||||
async def test_assign_issue_not_found(self, client, user_token, test_project):
|
||||
"""Test assigning a nonexistent issue."""
|
||||
project_id = test_project["id"]
|
||||
fake_issue_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}/assign",
|
||||
json={"human_assignee": "john.doe@example.com"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_unassign_issue_not_found(self, client, user_token, test_project):
|
||||
"""Test unassigning a nonexistent issue."""
|
||||
project_id = test_project["id"]
|
||||
fake_issue_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}/assignment",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_assign_issue_clears_assignment(self, client, user_token, test_project):
|
||||
"""Test that assigning to null clears both assignments."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue and assign
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue to Clear",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
||||
json={"human_assignee": "john.doe@example.com"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Clear assignment by sending empty object
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
||||
json={},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestIssueSync:
|
||||
"""Tests for issue sync endpoint."""
|
||||
|
||||
async def test_sync_issue_no_tracker(self, client, user_token, test_project):
|
||||
"""Test syncing an issue without external tracker."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue without external tracker
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue without Tracker",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Try to sync
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}/sync",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Should fail because no external tracker configured
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_sync_issue_not_found(self, client, user_token, test_project):
|
||||
"""Test syncing a nonexistent issue."""
|
||||
project_id = test_project["id"]
|
||||
fake_issue_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}/sync",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestIssueCrossProjectValidation:
|
||||
"""Tests for cross-project validation (IDOR prevention)."""
|
||||
|
||||
async def test_issue_not_in_project(self, client, user_token):
|
||||
"""Test accessing issue that exists but not in the specified project."""
|
||||
# Create two projects
|
||||
project1 = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Project 1", "slug": "project-1-idor"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
project2 = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Project 2", "slug": "project-2-idor"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
project1_id = project1.json()["id"]
|
||||
project2_id = project2.json()["id"]
|
||||
|
||||
# Create issue in project1
|
||||
issue_response = await client.post(
|
||||
f"/api/v1/projects/{project1_id}/issues",
|
||||
json={
|
||||
"project_id": project1_id,
|
||||
"title": "Project 1 Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = issue_response.json()["id"]
|
||||
|
||||
# Try to access issue via project2 (IDOR attempt)
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project2_id}/issues/{issue_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_update_issue_wrong_project(self, client, user_token):
|
||||
"""Test updating issue through wrong project."""
|
||||
# Create two projects
|
||||
project1 = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Project A", "slug": "project-a-idor"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
project2 = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Project B", "slug": "project-b-idor"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
project1_id = project1.json()["id"]
|
||||
project2_id = project2.json()["id"]
|
||||
|
||||
# Create issue in project1
|
||||
issue_response = await client.post(
|
||||
f"/api/v1/projects/{project1_id}/issues",
|
||||
json={
|
||||
"project_id": project1_id,
|
||||
"title": "Project A Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = issue_response.json()["id"]
|
||||
|
||||
# Try to update issue via project2 (IDOR attempt)
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project2_id}/issues/{issue_id}",
|
||||
json={"title": "Hacked Title"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_delete_issue_wrong_project(self, client, user_token):
|
||||
"""Test deleting issue through wrong project."""
|
||||
# Create two projects
|
||||
project1 = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Project X", "slug": "project-x-idor"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
project2 = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Project Y", "slug": "project-y-idor"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
project1_id = project1.json()["id"]
|
||||
project2_id = project2.json()["id"]
|
||||
|
||||
# Create issue in project1
|
||||
issue_response = await client.post(
|
||||
f"/api/v1/projects/{project1_id}/issues",
|
||||
json={
|
||||
"project_id": project1_id,
|
||||
"title": "Project X Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = issue_response.json()["id"]
|
||||
|
||||
# Try to delete issue via project2 (IDOR attempt)
|
||||
response = await client.delete(
|
||||
f"/api/v1/projects/{project2_id}/issues/{issue_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestIssueValidation:
|
||||
"""Tests for issue validation during create/update."""
|
||||
|
||||
async def test_create_issue_invalid_priority(self, client, user_token, test_project):
|
||||
"""Test creating issue with invalid priority."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue with Invalid Priority",
|
||||
"priority": "invalid_priority",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_create_issue_invalid_status(self, client, user_token, test_project):
|
||||
"""Test creating issue with invalid status."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue with Invalid Status",
|
||||
"status": "invalid_status",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_update_issue_invalid_priority(self, client, user_token, test_project):
|
||||
"""Test updating issue with invalid priority."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue to Update",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Update with invalid priority
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}",
|
||||
json={"priority": "invalid_priority"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_create_issue_with_nonexistent_sprint(
|
||||
self, client, user_token, test_project
|
||||
):
|
||||
"""Test creating issue with nonexistent sprint ID."""
|
||||
project_id = test_project["id"]
|
||||
fake_sprint_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue with Fake Sprint",
|
||||
"sprint_id": fake_sprint_id,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_create_issue_with_nonexistent_agent(
|
||||
self, client, user_token, test_project
|
||||
):
|
||||
"""Test creating issue with nonexistent agent ID."""
|
||||
project_id = test_project["id"]
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Issue with Fake Agent",
|
||||
"assigned_agent_id": fake_agent_id,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
Reference in New Issue
Block a user