From ddf9b5fe2595e3ac8c2c5d85a230873c2d0cb982 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Wed, 31 Dec 2025 14:04:05 +0100 Subject: [PATCH] test(sprints): add sprint issues and IDOR prevention tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TestSprintIssues class (5 tests) - List sprint issues (empty/with data) - Add issue to sprint - Add nonexistent issue to sprint - Add TestSprintCrossProjectValidation class (3 tests) - IDOR prevention for get/update/start through wrong project Coverage: sprints.py 72% → 76% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../tests/api/routes/syndarix/test_sprints.py | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/backend/tests/api/routes/syndarix/test_sprints.py b/backend/tests/api/routes/syndarix/test_sprints.py index 0b33bf3..5b7600d 100644 --- a/backend/tests/api/routes/syndarix/test_sprints.py +++ b/backend/tests/api/routes/syndarix/test_sprints.py @@ -819,3 +819,303 @@ class TestSprintAuthorization: ) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +class TestSprintIssues: + """Tests for sprint issue management endpoints.""" + + async def test_list_sprint_issues_empty(self, client, user_token, test_project): + """Test listing issues in a sprint with no issues.""" + project_id = test_project["id"] + start_date = date.today() + end_date = start_date + timedelta(days=14) + + # Create sprint + create_response = await client.post( + f"/api/v1/projects/{project_id}/sprints", + json={ + "project_id": project_id, + "name": "Empty Sprint", + "number": 1, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + sprint_id = create_response.json()["id"] + + # List issues + response = await client.get( + f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["data"] == [] + assert data["pagination"]["total"] == 0 + + async def test_list_sprint_issues_with_data(self, client, user_token, test_project): + """Test listing issues in a sprint with issues.""" + project_id = test_project["id"] + start_date = date.today() + end_date = start_date + timedelta(days=14) + + # Create sprint + create_response = await client.post( + f"/api/v1/projects/{project_id}/sprints", + json={ + "project_id": project_id, + "name": "Sprint with Issues", + "number": 1, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + sprint_id = create_response.json()["id"] + + # Create issue in the sprint + await client.post( + f"/api/v1/projects/{project_id}/issues", + json={ + "project_id": project_id, + "title": "Sprint Issue 1", + "sprint_id": sprint_id, + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + await client.post( + f"/api/v1/projects/{project_id}/issues", + json={ + "project_id": project_id, + "title": "Sprint Issue 2", + "sprint_id": sprint_id, + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + # List issues + response = await client.get( + f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["data"]) == 2 + assert data["pagination"]["total"] == 2 + + async def test_list_sprint_issues_not_found(self, client, user_token, test_project): + """Test listing issues in a nonexistent sprint.""" + project_id = test_project["id"] + fake_sprint_id = str(uuid.uuid4()) + + response = await client.get( + f"/api/v1/projects/{project_id}/sprints/{fake_sprint_id}/issues", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_add_issue_to_sprint(self, client, user_token, test_project): + """Test adding an issue to a sprint.""" + project_id = test_project["id"] + start_date = date.today() + end_date = start_date + timedelta(days=14) + + # Create sprint + sprint_response = await client.post( + f"/api/v1/projects/{project_id}/sprints", + json={ + "project_id": project_id, + "name": "Target Sprint", + "number": 1, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + sprint_id = sprint_response.json()["id"] + + # Create issue without sprint + issue_response = await client.post( + f"/api/v1/projects/{project_id}/issues", + json={ + "project_id": project_id, + "title": "Issue to Add", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + issue_id = issue_response.json()["id"] + + # Add issue to sprint + response = await client.post( + f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues", + params={"issue_id": issue_id}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + async def test_add_nonexistent_issue_to_sprint( + self, client, user_token, test_project + ): + """Test adding a nonexistent issue to a sprint.""" + project_id = test_project["id"] + start_date = date.today() + end_date = start_date + timedelta(days=14) + + # Create sprint + sprint_response = await client.post( + f"/api/v1/projects/{project_id}/sprints", + json={ + "project_id": project_id, + "name": "Target Sprint", + "number": 1, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + sprint_id = sprint_response.json()["id"] + fake_issue_id = str(uuid.uuid4()) + + # Try to add nonexistent issue + response = await client.post( + f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues", + params={"issue_id": fake_issue_id}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +class TestSprintCrossProjectValidation: + """Tests for cross-project validation (IDOR prevention).""" + + async def test_sprint_not_in_project(self, client, user_token): + """Test accessing sprint that exists but not in the specified project.""" + # Create two projects + project1 = await client.post( + "/api/v1/projects", + json={"name": "Project 1 Sprint", "slug": "project-1-sprint-idor"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + project2 = await client.post( + "/api/v1/projects", + json={"name": "Project 2 Sprint", "slug": "project-2-sprint-idor"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + project1_id = project1.json()["id"] + project2_id = project2.json()["id"] + + start_date = date.today() + end_date = start_date + timedelta(days=14) + + # Create sprint in project1 + sprint_response = await client.post( + f"/api/v1/projects/{project1_id}/sprints", + json={ + "project_id": project1_id, + "name": "Project 1 Sprint", + "number": 1, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + sprint_id = sprint_response.json()["id"] + + # Try to access sprint via project2 (IDOR attempt) + response = await client.get( + f"/api/v1/projects/{project2_id}/sprints/{sprint_id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_update_sprint_wrong_project(self, client, user_token): + """Test updating sprint through wrong project.""" + # Create two projects + project1 = await client.post( + "/api/v1/projects", + json={"name": "Project A Sprint", "slug": "project-a-sprint-idor"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + project2 = await client.post( + "/api/v1/projects", + json={"name": "Project B Sprint", "slug": "project-b-sprint-idor"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + project1_id = project1.json()["id"] + project2_id = project2.json()["id"] + + start_date = date.today() + end_date = start_date + timedelta(days=14) + + # Create sprint in project1 + sprint_response = await client.post( + f"/api/v1/projects/{project1_id}/sprints", + json={ + "project_id": project1_id, + "name": "Project A Sprint", + "number": 1, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + sprint_id = sprint_response.json()["id"] + + # Try to update sprint via project2 (IDOR attempt) + response = await client.patch( + f"/api/v1/projects/{project2_id}/sprints/{sprint_id}", + json={"name": "Hacked Name"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_start_sprint_wrong_project(self, client, user_token): + """Test starting sprint through wrong project.""" + # Create two projects + project1 = await client.post( + "/api/v1/projects", + json={"name": "Project X Sprint", "slug": "project-x-sprint-idor"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + project2 = await client.post( + "/api/v1/projects", + json={"name": "Project Y Sprint", "slug": "project-y-sprint-idor"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + project1_id = project1.json()["id"] + project2_id = project2.json()["id"] + + start_date = date.today() + end_date = start_date + timedelta(days=14) + + # Create sprint in project1 + sprint_response = await client.post( + f"/api/v1/projects/{project1_id}/sprints", + json={ + "project_id": project1_id, + "name": "Project X Sprint", + "number": 1, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + sprint_id = sprint_response.json()["id"] + + # Try to start sprint via project2 (IDOR attempt) + response = await client.post( + f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/start", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND