snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
4
apps/api/plane/tests/contract/__init__.py
Normal file
4
apps/api/plane/tests/contract/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
4
apps/api/plane/tests/contract/api/__init__.py
Normal file
4
apps/api/plane/tests/contract/api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
44
apps/api/plane/tests/contract/api/test_authentication.py
Normal file
44
apps/api/plane/tests/contract/api/test_authentication.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Contract tests for external API key authentication.
|
||||
|
||||
End-to-end proof that an API key cannot be used to access the API once the
|
||||
owning user account has been deactivated.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestAPIKeyAuthenticationContract:
|
||||
"""Test API key authentication behaviour against a live endpoint."""
|
||||
|
||||
USERS_ME_URL = "/api/v1/users/me/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_active_user_can_access_with_api_key(self, api_key_client):
|
||||
response = api_key_client.get(self.USERS_ME_URL)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_deactivated_user_cannot_access_with_api_key(
|
||||
self, api_key_client, create_user
|
||||
):
|
||||
# The account is disabled after the API key was generated.
|
||||
create_user.is_active = False
|
||||
create_user.save()
|
||||
|
||||
response = api_key_client.get(self.USERS_ME_URL)
|
||||
|
||||
# Access is denied once the account is deactivated. APIKeyAuthentication
|
||||
# does not set a WWW-Authenticate header, so DRF surfaces the
|
||||
# AuthenticationFailed as 403 Forbidden rather than 401.
|
||||
assert response.status_code in (
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
385
apps/api/plane/tests/contract/api/test_cycles.py
Normal file
385
apps/api/plane/tests/contract/api/test_cycles.py
Normal file
@@ -0,0 +1,385 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.db.models import Cycle, Project, ProjectMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the user as a member"""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
cycle_view=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycle_data():
|
||||
"""Sample cycle data for tests"""
|
||||
return {
|
||||
"name": "Test Cycle",
|
||||
"description": "A test cycle for unit tests",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def draft_cycle_data():
|
||||
"""Sample draft cycle data (no dates)"""
|
||||
return {
|
||||
"name": "Draft Cycle",
|
||||
"description": "A draft cycle without dates",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_cycle(db, project, create_user):
|
||||
"""Create a test cycle"""
|
||||
return Cycle.objects.create(
|
||||
name="Existing Cycle",
|
||||
description="An existing cycle",
|
||||
start_date=timezone.now() + timedelta(days=1),
|
||||
end_date=timezone.now() + timedelta(days=7),
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestCycleListCreateAPIEndpoint:
|
||||
"""Test Cycle List and Create API Endpoint"""
|
||||
|
||||
def get_cycle_url(self, workspace_slug, project_id):
|
||||
"""Helper to get cycle endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_success(self, api_key_client, workspace, project, cycle_data):
|
||||
"""Test successful cycle creation"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(url, cycle_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
assert Cycle.objects.count() == 1
|
||||
|
||||
created_cycle = Cycle.objects.first()
|
||||
assert created_cycle.name == cycle_data["name"]
|
||||
assert created_cycle.description == cycle_data["description"]
|
||||
assert created_cycle.project == project
|
||||
assert created_cycle.owned_by_id is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_invalid_data(self, api_key_client, workspace, project):
|
||||
"""Test cycle creation with invalid data"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Test with empty data
|
||||
response = api_key_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test with missing name
|
||||
response = api_key_client.post(url, {"description": "Test cycle"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_invalid_date_combination(self, api_key_client, workspace, project):
|
||||
"""Test cycle creation with invalid date combination (only start_date)"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
invalid_data = {
|
||||
"name": "Invalid Cycle",
|
||||
"start_date": (timezone.now() + timedelta(days=1)).isoformat(),
|
||||
# Missing end_date
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, invalid_data, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Both start date and end date are either required or are to be null" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_with_external_id(self, api_key_client, workspace, project):
|
||||
"""Test creating cycle with external ID"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
cycle_data = {
|
||||
"name": "External Cycle",
|
||||
"description": "A cycle with external ID",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, cycle_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
created_cycle = Cycle.objects.first()
|
||||
assert created_cycle.external_id == "ext-123"
|
||||
assert created_cycle.external_source == "github"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_duplicate_external_id(self, api_key_client, workspace, project, create_user):
|
||||
"""Test creating cycle with duplicate external ID"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Create first cycle
|
||||
Cycle.objects.create(
|
||||
name="First Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
external_id="ext-123",
|
||||
external_source="github",
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Try to create second cycle with same external ID
|
||||
cycle_data = {
|
||||
"name": "Second Cycle",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
"owned_by": create_user.id,
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, cycle_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "same external id" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_cycles_success(self, api_key_client, workspace, project, create_cycle, create_user):
|
||||
"""Test successful cycle listing"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Create additional cycles
|
||||
Cycle.objects.create(
|
||||
name="Cycle 2",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=timezone.now() + timedelta(days=10),
|
||||
end_date=timezone.now() + timedelta(days=17),
|
||||
owned_by=create_user,
|
||||
)
|
||||
Cycle.objects.create(
|
||||
name="Cycle 3",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=timezone.now() + timedelta(days=20),
|
||||
end_date=timezone.now() + timedelta(days=27),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
assert len(response.data["results"]) == 3 # Including create_cycle fixture
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_cycles_with_view_filter(self, api_key_client, workspace, project, create_user):
|
||||
"""Test cycle listing with different view filters"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Create cycles in different states
|
||||
now = timezone.now()
|
||||
|
||||
# Current cycle (started but not ended)
|
||||
Cycle.objects.create(
|
||||
name="Current Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=now - timedelta(days=1),
|
||||
end_date=now + timedelta(days=6),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Upcoming cycle
|
||||
Cycle.objects.create(
|
||||
name="Upcoming Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=now + timedelta(days=1),
|
||||
end_date=now + timedelta(days=8),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Completed cycle
|
||||
Cycle.objects.create(
|
||||
name="Completed Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=now - timedelta(days=10),
|
||||
end_date=now - timedelta(days=3),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Draft cycle
|
||||
Cycle.objects.create(
|
||||
name="Draft Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Test current cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "current"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
assert response.data[0]["name"] == "Current Cycle"
|
||||
|
||||
# Test upcoming cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "upcoming"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Upcoming Cycle"
|
||||
|
||||
# Test completed cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "completed"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Completed Cycle"
|
||||
|
||||
# Test draft cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "draft"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Draft Cycle"
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestCycleDetailAPIEndpoint:
|
||||
"""Test Cycle Detail API Endpoint"""
|
||||
|
||||
def get_cycle_detail_url(self, workspace_slug, project_id, cycle_id):
|
||||
"""Helper to get cycle detail endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cycle_success(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test successful cycle retrieval"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_cycle.id)
|
||||
assert response.data["name"] == create_cycle.name
|
||||
assert response.data["description"] == create_cycle.description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cycle_not_found(self, api_key_client, workspace, project):
|
||||
"""Test getting non-existent cycle"""
|
||||
fake_id = uuid4()
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, fake_id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_cycle_success(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test successful cycle update"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
update_data = {
|
||||
"name": f"Updated Cycle {uuid4()}",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
create_cycle.refresh_from_db()
|
||||
assert create_cycle.name == update_data["name"]
|
||||
assert create_cycle.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_cycle_invalid_data(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test cycle update with invalid data"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
update_data = {"name": ""}
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
# This might be 400 if name is required, or 200 if empty names are allowed
|
||||
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_cycle_with_external_id_conflict(
|
||||
self, api_key_client, workspace, project, create_cycle, create_user
|
||||
):
|
||||
"""Test cycle update with conflicting external ID"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
# Create another cycle with external ID
|
||||
Cycle.objects.create(
|
||||
name="Another Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
external_id="ext-456",
|
||||
external_source="github",
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Try to update cycle with same external ID
|
||||
update_data = {
|
||||
"external_id": "ext-456",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "same external id" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_cycle_success(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test successful cycle deletion"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
response = api_key_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Cycle.objects.filter(id=create_cycle.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cycle_metrics_annotation(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test that cycle includes issue metrics annotations"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Check that metrics are included in response
|
||||
cycle_data = response.data
|
||||
assert "total_issues" in cycle_data
|
||||
assert "completed_issues" in cycle_data
|
||||
assert "cancelled_issues" in cycle_data
|
||||
assert "started_issues" in cycle_data
|
||||
assert "unstarted_issues" in cycle_data
|
||||
assert "backlog_issues" in cycle_data
|
||||
|
||||
# All should be 0 for a new cycle
|
||||
assert cycle_data["total_issues"] == 0
|
||||
assert cycle_data["completed_issues"] == 0
|
||||
assert cycle_data["cancelled_issues"] == 0
|
||||
assert cycle_data["started_issues"] == 0
|
||||
assert cycle_data["unstarted_issues"] == 0
|
||||
assert cycle_data["backlog_issues"] == 0
|
||||
72
apps/api/plane/tests/contract/api/test_cycles_lite.py
Normal file
72
apps/api/plane/tests/contract/api/test_cycles_lite.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for the cycles-lite endpoint.
|
||||
|
||||
GET /api/v1/workspaces/<slug>/projects/<project_id>/cycles-lite/
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Cycle, Project, ProjectMember
|
||||
|
||||
|
||||
def _url(slug, project_id):
|
||||
return f"/api/v1/workspaces/{slug}/projects/{project_id}/cycles-lite/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
project = Project.objects.create(
|
||||
name="Cycle Lite Project",
|
||||
identifier="CLP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
cycle_view=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycles(db, project, create_user):
|
||||
now = timezone.now()
|
||||
active = Cycle.objects.create(
|
||||
name="Active Cycle",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
owned_by=create_user,
|
||||
start_date=now,
|
||||
end_date=now + timedelta(days=7),
|
||||
)
|
||||
archived = Cycle.objects.create(
|
||||
name="Archived Cycle",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
owned_by=create_user,
|
||||
archived_at=now,
|
||||
)
|
||||
return {"active": active, "archived": archived}
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestCyclesLite:
|
||||
@pytest.mark.django_db
|
||||
def test_paginated_and_excludes_archived(self, api_key_client, workspace, project, cycles):
|
||||
response = api_key_client.get(_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
names = {item["name"] for item in response.data["results"]}
|
||||
assert "Active Cycle" in names
|
||||
assert "Archived Cycle" not in names
|
||||
143
apps/api/plane/tests/contract/api/test_generic_asset.py
Normal file
143
apps/api/plane/tests/contract/api/test_generic_asset.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for the public REST API ``GenericAssetEndpoint``.
|
||||
|
||||
Regression coverage for the cross-workspace asset IDOR (the unfixed
|
||||
external-API sibling of CVE-2026-46558 / GHSA-qw87-v5w3-6vxx). The endpoint
|
||||
must reject any caller that is not an active member of the workspace named in
|
||||
the URL slug, regardless of the workspace their Personal Access Token came
|
||||
from.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import FileAsset, User, Workspace, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_user(db):
|
||||
"""A user that owns a separate workspace the attacker is not part of."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"victim-{unique_id}@plane.so",
|
||||
username=f"victim_{unique_id}",
|
||||
first_name="Victim",
|
||||
last_name="User",
|
||||
)
|
||||
user.set_password("test-password")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_workspace(db, victim_user):
|
||||
"""A workspace whose only active member is ``victim_user``.
|
||||
|
||||
The attacker (``create_user``, who authenticates ``api_key_client``) is
|
||||
deliberately NOT a member here.
|
||||
"""
|
||||
workspace = Workspace.objects.create(
|
||||
name="Victim Workspace",
|
||||
owner=victim_user,
|
||||
slug="victim-workspace",
|
||||
)
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=victim_user, role=20)
|
||||
return workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_asset(db, victim_workspace, victim_user):
|
||||
"""An uploaded attachment that lives inside the victim workspace.
|
||||
|
||||
``storage_metadata`` is pre-populated so the PATCH handler does not enqueue
|
||||
the metadata Celery task during the test.
|
||||
"""
|
||||
return FileAsset.objects.create(
|
||||
attributes={"name": "secret.pdf", "type": "application/pdf", "size": 1024},
|
||||
asset=f"{victim_workspace.id}/secret.pdf",
|
||||
size=1024,
|
||||
workspace=victim_workspace,
|
||||
created_by=victim_user,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=True,
|
||||
storage_metadata={"size": 1024},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestGenericAssetCrossWorkspaceIDOR:
|
||||
"""A PAT holder must not reach assets in a workspace they don't belong to."""
|
||||
|
||||
def detail_url(self, slug, asset_id):
|
||||
return f"/api/v1/workspaces/{slug}/assets/{asset_id}/"
|
||||
|
||||
def list_url(self, slug):
|
||||
return f"/api/v1/workspaces/{slug}/assets/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset):
|
||||
"""GET on another workspace's asset must be forbidden, not return a
|
||||
presigned download URL."""
|
||||
url = self.detail_url(victim_workspace.slug, victim_asset.id)
|
||||
|
||||
with mock.patch("plane.api.views.asset.S3Storage") as mock_storage:
|
||||
mock_storage.return_value.generate_presigned_url.return_value = "https://signed.example/download"
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
|
||||
# The S3 download URL must never be minted for a non-member.
|
||||
mock_storage.return_value.generate_presigned_url.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_post_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace):
|
||||
"""POST (upload) into another workspace must be forbidden and must not
|
||||
plant an asset row in the victim workspace."""
|
||||
url = self.list_url(victim_workspace.slug)
|
||||
payload = {"name": "evil.pdf", "type": "application/pdf", "size": 1024}
|
||||
|
||||
with mock.patch("plane.api.views.asset.S3Storage") as mock_storage:
|
||||
mock_storage.return_value.generate_presigned_post.return_value = {"url": "x", "fields": {}}
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
|
||||
assert FileAsset.objects.filter(workspace=victim_workspace).count() == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset):
|
||||
"""PATCH on another workspace's asset must be forbidden and must leave
|
||||
the asset untouched."""
|
||||
url = self.detail_url(victim_workspace.slug, victim_asset.id)
|
||||
|
||||
response = api_key_client.patch(url, {"is_uploaded": False}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
|
||||
victim_asset.refresh_from_db()
|
||||
assert victim_asset.is_uploaded is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_member_can_patch_own_workspace_asset(self, api_key_client, workspace, create_user):
|
||||
"""Positive control: an active member of the workspace can still update
|
||||
their own asset, so the fix does not over-block legitimate callers."""
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={"name": "mine.pdf", "type": "application/pdf", "size": 10},
|
||||
asset=f"{workspace.id}/mine.pdf",
|
||||
size=10,
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=False,
|
||||
storage_metadata={"size": 10},
|
||||
)
|
||||
url = self.detail_url(workspace.slug, asset.id)
|
||||
|
||||
response = api_key_client.patch(url, {"is_uploaded": True}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}"
|
||||
asset.refresh_from_db()
|
||||
assert asset.is_uploaded is True
|
||||
@@ -0,0 +1,136 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.celery import app as celery_app
|
||||
from plane.db.models import Issue, Label, Project, ProjectMember, State, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def celery_eager():
|
||||
"""
|
||||
Run Celery tasks synchronously in-process instead of publishing to a
|
||||
broker. There's no RabbitMQ/broker in this local sandbox, and these
|
||||
tests only care about the HTTP response contract, not async delivery.
|
||||
"""
|
||||
original = celery_app.conf.task_always_eager
|
||||
celery_app.conf.task_always_eager = True
|
||||
celery_app.conf.task_eager_propagates = False
|
||||
yield
|
||||
celery_app.conf.task_always_eager = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the user as an admin member and a default state."""
|
||||
project = Project.objects.create(
|
||||
name="Test Project", identifier="TP", workspace=workspace, created_by=create_user
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
State.objects.create(
|
||||
name="Backlog",
|
||||
color="#000000",
|
||||
group="backlog",
|
||||
default=True,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_issue(db, project, workspace, create_user):
|
||||
return Issue.objects.create(name="Existing Issue", project=project, workspace=workspace, created_by=create_user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_user(db):
|
||||
"""A user who exists in the workspace/system but is NOT a member of `project`."""
|
||||
user = User.objects.create(email="outsider@plane.so", username="outsider-user")
|
||||
user.set_password("outsider-password")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestIssueAssigneeLabelValidationContract:
|
||||
"""
|
||||
Contract: creating/updating a work item through the external REST API
|
||||
(``/api/v1/...``) must reject assignee/label ids that don't belong to the
|
||||
project with a 400, instead of silently dropping them and returning
|
||||
200/201. See makeplane/plane#9517.
|
||||
"""
|
||||
|
||||
def get_list_url(self, workspace_slug, project_id):
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/"
|
||||
|
||||
def get_detail_url(self, workspace_slug, project_id, issue_id):
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_with_non_member_assignee_is_rejected(self, api_key_client, workspace, project, outsider_user):
|
||||
url = self.get_list_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(
|
||||
url, {"name": "New Issue", "assignees": [str(outsider_user.id)]}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Issue.objects.filter(name="New Issue").exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_with_foreign_label_is_rejected(self, api_key_client, workspace, project):
|
||||
other_project = Project.objects.create(name="Other", identifier="OTH", workspace=workspace)
|
||||
foreign_label = Label.objects.create(name="Foreign", project=other_project)
|
||||
|
||||
url = self.get_list_url(workspace.slug, project.id)
|
||||
response = api_key_client.post(
|
||||
url, {"name": "New Issue", "labels": [str(foreign_label.id)]}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Issue.objects.filter(name="New Issue").exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_with_non_member_assignee_is_rejected_and_leaves_assignees_unchanged(
|
||||
self, api_key_client, workspace, project, create_issue, outsider_user
|
||||
):
|
||||
url = self.get_detail_url(workspace.slug, project.id, create_issue.id)
|
||||
|
||||
response = api_key_client.patch(url, {"assignees": [str(outsider_user.id)]}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert list(create_issue.assignees.all()) == []
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_with_mix_of_valid_and_invalid_assignee_is_rejected_entirely(
|
||||
self, api_key_client, workspace, project, create_user, outsider_user
|
||||
):
|
||||
"""A partially-valid list must reject the whole request, not silently keep only the valid id."""
|
||||
url = self.get_list_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(
|
||||
url,
|
||||
{"name": "New Issue", "assignees": [str(create_user.id), str(outsider_user.id)]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Issue.objects.filter(name="New Issue").exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_with_valid_assignee_still_works(self, api_key_client, workspace, project, create_user):
|
||||
"""Regression guard: a genuinely valid project member must still be assignable."""
|
||||
url = self.get_list_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(
|
||||
url, {"name": "New Issue", "assignees": [str(create_user.id)]}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
issue = Issue.objects.get(name="New Issue")
|
||||
assert list(issue.assignees.values_list("id", flat=True)) == [create_user.id]
|
||||
135
apps/api/plane/tests/contract/api/test_issue_notifications.py
Normal file
135
apps/api/plane/tests/contract/api/test_issue_notifications.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Issue, Project, ProjectMember, State, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the user as an admin member and a default state."""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
is_active=True,
|
||||
)
|
||||
# A default state is required to create work items through the API
|
||||
State.objects.create(
|
||||
name="Backlog",
|
||||
color="#000000",
|
||||
group="backlog",
|
||||
default=True,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_issue(db, project, workspace, create_user):
|
||||
"""Create an existing work item to update/assign in tests."""
|
||||
return Issue.objects.create(
|
||||
name="Existing Issue",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assignee_user(db):
|
||||
"""Create a second user that can be assigned to a work item."""
|
||||
user = User.objects.create(
|
||||
email="assignee@plane.so",
|
||||
username="assignee-user",
|
||||
first_name="Assignee",
|
||||
last_name="User",
|
||||
)
|
||||
user.set_password("assignee-password")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestIssueNotificationContract:
|
||||
"""
|
||||
Contract: creating and updating/assigning a work item through the external
|
||||
REST API (``/api/v1/...``) must trigger notifications, i.e. ``issue_activity``
|
||||
is dispatched with ``notification=True`` so subscribers and assignees are
|
||||
notified the same way the web app does. See makeplane/plane#9306.
|
||||
"""
|
||||
|
||||
def get_list_url(self, workspace_slug, project_id):
|
||||
"""Helper to build the work item list/create endpoint URL."""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/"
|
||||
|
||||
def get_detail_url(self, workspace_slug, project_id, issue_id):
|
||||
"""Helper to build the work item detail endpoint URL."""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_issue_triggers_notification(self, api_key_client, workspace, project):
|
||||
"""Creating a work item via the external API dispatches a notifying activity."""
|
||||
url = self.get_list_url(workspace.slug, project.id)
|
||||
|
||||
with patch("plane.api.views.issue.issue_activity") as mock_issue_activity:
|
||||
response = api_key_client.post(url, {"name": "New Issue"}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert Issue.objects.filter(name="New Issue").exists()
|
||||
|
||||
mock_issue_activity.delay.assert_called_once()
|
||||
kwargs = mock_issue_activity.delay.call_args.kwargs
|
||||
assert kwargs["type"] == "issue.activity.created"
|
||||
assert kwargs["notification"] is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_issue_triggers_notification(self, api_key_client, workspace, project, create_issue):
|
||||
"""Updating a work item via the external API dispatches a notifying activity."""
|
||||
url = self.get_detail_url(workspace.slug, project.id, create_issue.id)
|
||||
|
||||
with patch("plane.api.views.issue.issue_activity") as mock_issue_activity:
|
||||
response = api_key_client.patch(url, {"name": "Renamed Issue"}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_issue.refresh_from_db()
|
||||
assert create_issue.name == "Renamed Issue"
|
||||
|
||||
mock_issue_activity.delay.assert_called_once()
|
||||
kwargs = mock_issue_activity.delay.call_args.kwargs
|
||||
assert kwargs["type"] == "issue.activity.updated"
|
||||
assert kwargs["notification"] is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_issue_triggers_notification(self, api_key_client, workspace, project, create_issue, assignee_user):
|
||||
"""Assigning a work item via the external API dispatches a notifying activity."""
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=assignee_user,
|
||||
role=15, # Member role
|
||||
is_active=True,
|
||||
)
|
||||
url = self.get_detail_url(workspace.slug, project.id, create_issue.id)
|
||||
|
||||
with patch("plane.api.views.issue.issue_activity") as mock_issue_activity:
|
||||
response = api_key_client.patch(url, {"assignees": [str(assignee_user.id)]}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
mock_issue_activity.delay.assert_called_once()
|
||||
kwargs = mock_issue_activity.delay.call_args.kwargs
|
||||
assert kwargs["type"] == "issue.activity.updated"
|
||||
assert kwargs["notification"] is True
|
||||
96
apps/api/plane/tests/contract/api/test_issues.py
Normal file
96
apps/api/plane/tests/contract/api/test_issues.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Issue, Project, ProjectMember, State
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the requesting user as an active member."""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20, # Admin
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(db, workspace, project):
|
||||
return State.objects.create(
|
||||
name="Todo",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
group="backlog",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issue(db, workspace, project, state, create_user):
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
state=state,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestIssueListOrderByInjection:
|
||||
"""Regression tests for GHSA-p885-6jpg-cr2p on the work-item list
|
||||
endpoint: GET /api/v1/workspaces/{slug}/projects/{project_id}/issues/.
|
||||
|
||||
The raw ``order_by`` query parameter fell through the endpoint's hardcoded
|
||||
branch logic to ``issue_queryset.order_by(order_by_param)``, letting an
|
||||
attacker order by sensitive related columns (blind oracle) or crash the
|
||||
endpoint with an unknown field (HTTP 500). The fix sanitizes the parameter
|
||||
against ISSUE_ORDER_BY_ALLOWLIST before the branch logic runs.
|
||||
"""
|
||||
|
||||
def get_url(self, workspace_slug, project_id):
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_order_by_does_not_500(self, api_key_client, workspace, project, issue):
|
||||
"""Unknown field used to raise FieldError → HTTP 500; now sanitized to
|
||||
the safe default and returns 200 (DoS half of the advisory)."""
|
||||
url = self.get_url(workspace.slug, project.id)
|
||||
response = api_key_client.get(url, {"order_by": "not_a_field"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_relational_order_by_injection_does_not_500(self, api_key_client, workspace, project, issue):
|
||||
"""Ordering by a related-table column (``created_by__password``) used to
|
||||
reach ``.order_by()`` raw, forming a blind ordering oracle. It is now
|
||||
neutralized to the safe default. (Deterministic neutralization is
|
||||
asserted in tests/unit/utils/test_order_by_sanitize.py.)"""
|
||||
url = self.get_url(workspace.slug, project.id)
|
||||
response = api_key_client.get(url, {"order_by": "created_by__password"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_legitimate_order_by_still_works(self, api_key_client, workspace, project, issue):
|
||||
"""A valid, allowlisted ordering value continues to return 200 —
|
||||
the sanitizer must not break legitimate ordering."""
|
||||
url = self.get_url(workspace.slug, project.id)
|
||||
|
||||
for value in ["-created_at", "priority", "state__group", "sequence_id"]:
|
||||
response = api_key_client.get(url, {"order_by": value})
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"order_by={value!r} got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
217
apps/api/plane/tests/contract/api/test_labels.py
Normal file
217
apps/api/plane/tests/contract/api/test_labels.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.db.models import Label, Project, ProjectMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the user as a member"""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def label_data():
|
||||
"""Sample label data for tests"""
|
||||
return {
|
||||
"name": "Test Label",
|
||||
"color": "#FF5733",
|
||||
"description": "A test label for unit tests",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_label(db, project, create_user):
|
||||
"""Create a test label"""
|
||||
return Label.objects.create(
|
||||
name="Existing Label",
|
||||
color="#00FF00",
|
||||
description="An existing label",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestLabelListCreateAPIEndpoint:
|
||||
"""Test Label List and Create API Endpoint"""
|
||||
|
||||
def get_label_url(self, workspace_slug, project_id):
|
||||
"""Helper to get label endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_success(self, api_key_client, workspace, project, label_data):
|
||||
"""Test successful label creation"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert Label.objects.count() == 1
|
||||
|
||||
created_label = Label.objects.first()
|
||||
assert created_label.name == label_data["name"]
|
||||
assert created_label.color == label_data["color"]
|
||||
assert created_label.description == label_data["description"]
|
||||
assert created_label.project == project
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_invalid_data(self, api_key_client, workspace, project):
|
||||
"""Test label creation with invalid data"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Test with empty data
|
||||
response = api_key_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test with missing name
|
||||
response = api_key_client.post(url, {"color": "#FF5733"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_with_external_id(self, api_key_client, workspace, project):
|
||||
"""Test creating label with external ID"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
label_data = {
|
||||
"name": "External Label",
|
||||
"color": "#FF5733",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
created_label = Label.objects.first()
|
||||
assert created_label.external_id == "ext-123"
|
||||
assert created_label.external_source == "github"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_duplicate_external_id(self, api_key_client, workspace, project):
|
||||
"""Test creating label with duplicate external ID"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Create first label
|
||||
Label.objects.create(
|
||||
name="First Label",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
external_id="ext-123",
|
||||
external_source="github",
|
||||
)
|
||||
|
||||
# Try to create second label with same external ID
|
||||
label_data = {
|
||||
"name": "Second Label",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "same external id" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_labels_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label listing"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Create additional labels
|
||||
Label.objects.create(name="Label 2", project=project, workspace=workspace, color="#00FF00")
|
||||
Label.objects.create(name="Label 3", project=project, workspace=workspace, color="#0000FF")
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
assert len(response.data["results"]) == 3 # Including create_label fixture
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestLabelDetailAPIEndpoint:
|
||||
"""Test Label Detail API Endpoint"""
|
||||
|
||||
def get_label_detail_url(self, workspace_slug, project_id, label_id):
|
||||
"""Helper to get label detail endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/{label_id}/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_label_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label retrieval"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["id"] == create_label.id
|
||||
assert response.data["name"] == create_label.name
|
||||
assert response.data["color"] == create_label.color
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_label_not_found(self, api_key_client, workspace, project):
|
||||
"""Test getting non-existent label"""
|
||||
from uuid import uuid4
|
||||
|
||||
fake_id = uuid4()
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, fake_id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_label_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label update"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
update_data = {
|
||||
"name": f"Updated Label {uuid4()}",
|
||||
}
|
||||
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
create_label.refresh_from_db()
|
||||
assert create_label.name == update_data["name"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_label_invalid_data(self, api_key_client, workspace, project, create_label):
|
||||
"""Test label update with invalid data"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
update_data = {"name": ""}
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
# This might be 400 if name is required, or 200 if empty names are allowed
|
||||
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_label_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label deletion"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
response = api_key_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Label.objects.filter(id=create_label.id).exists()
|
||||
102
apps/api/plane/tests/contract/api/test_members_lite.py
Normal file
102
apps/api/plane/tests/contract/api/test_members_lite.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for the workspace/project members-lite endpoints.
|
||||
|
||||
GET /api/v1/workspaces/<slug>/members-lite/
|
||||
GET /api/v1/workspaces/<slug>/projects/<project_id>/project-members-lite/
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Project, ProjectMember
|
||||
|
||||
_LITE_MEMBER_FIELDS = (
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"display_name",
|
||||
"role",
|
||||
"is_active",
|
||||
"is_bot",
|
||||
)
|
||||
|
||||
|
||||
def _ws_url(slug):
|
||||
return f"/api/v1/workspaces/{slug}/members-lite/"
|
||||
|
||||
|
||||
def _project_url(slug, project_id):
|
||||
return f"/api/v1/workspaces/{slug}/projects/{project_id}/project-members-lite/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
project = Project.objects.create(
|
||||
name="Members Lite Project",
|
||||
identifier="MLP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWorkspaceMembersLite:
|
||||
@pytest.mark.django_db
|
||||
def test_returns_paginated_member(self, api_key_client, workspace):
|
||||
response = api_key_client.get(_ws_url(workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
emails = {item["email"] for item in response.data["results"]}
|
||||
assert "test@plane.so" in emails
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lite_member_shape(self, api_key_client, workspace):
|
||||
response = api_key_client.get(_ws_url(workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
item = response.data["results"][0]
|
||||
for key in _LITE_MEMBER_FIELDS:
|
||||
assert key in item
|
||||
# The requesting user is the workspace owner (admin role = 20).
|
||||
assert item["role"] == 20
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_workspace_is_rejected(self, api_key_client):
|
||||
response = api_key_client.get(_ws_url("does-not-exist"))
|
||||
assert response.status_code in (
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectMembersLite:
|
||||
@pytest.mark.django_db
|
||||
def test_returns_paginated_member(self, api_key_client, workspace, project):
|
||||
response = api_key_client.get(_project_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
emails = {item["email"] for item in response.data["results"]}
|
||||
assert "test@plane.so" in emails
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lite_member_shape(self, api_key_client, workspace, project):
|
||||
response = api_key_client.get(_project_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
item = response.data["results"][0]
|
||||
for key in _LITE_MEMBER_FIELDS:
|
||||
assert key in item
|
||||
61
apps/api/plane/tests/contract/api/test_modules_lite.py
Normal file
61
apps/api/plane/tests/contract/api/test_modules_lite.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for the modules-lite endpoint.
|
||||
|
||||
GET /api/v1/workspaces/<slug>/projects/<project_id>/modules-lite/
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Module, Project, ProjectMember
|
||||
|
||||
|
||||
def _url(slug, project_id):
|
||||
return f"/api/v1/workspaces/{slug}/projects/{project_id}/modules-lite/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
project = Project.objects.create(
|
||||
name="Module Lite Project",
|
||||
identifier="MDL",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
module_view=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def modules(db, project):
|
||||
active = Module.objects.create(name="Active Module", project=project, workspace=project.workspace)
|
||||
archived = Module.objects.create(
|
||||
name="Archived Module",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
archived_at=timezone.now(),
|
||||
)
|
||||
return {"active": active, "archived": archived}
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestModulesLite:
|
||||
@pytest.mark.django_db
|
||||
def test_paginated_and_excludes_archived(self, api_key_client, workspace, project, modules):
|
||||
response = api_key_client.get(_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
names = {item["name"] for item in response.data["results"]}
|
||||
assert "Active Module" in names
|
||||
assert "Archived Module" not in names
|
||||
@@ -0,0 +1,101 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for ``ProjectMemberListCreateAPIEndpoint`` (v1) authorization.
|
||||
|
||||
Regression coverage for GHSA-w2vf-m9x9-mvmc (WEB-8075). The SAFE_METHODS branch
|
||||
of ``ProjectMemberPermission`` only checked workspace membership, so a workspace
|
||||
member who was NOT a member of a project could ``GET
|
||||
/workspaces/<slug>/projects/<pid>/members/`` and read that project's full roster.
|
||||
|
||||
The fix scopes the SAFE_METHODS check to ``project_id=view.project_id`` so a
|
||||
non-member is rejected with 403.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Project, ProjectMember, User
|
||||
|
||||
|
||||
def members_url(slug, project_id):
|
||||
return f"/api/v1/workspaces/{slug}/projects/{project_id}/members/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attacker_membership(db, workspace, create_user):
|
||||
"""Make the token holder (``create_user``) a member of an *unrelated* project.
|
||||
|
||||
The vulnerable SAFE_METHODS check was ``ProjectMember`` filtered by workspace
|
||||
only (no project_id), so being a member of ANY project in the workspace let
|
||||
the user read a foreign project's roster. Without this the request is denied
|
||||
for the unrelated reason of having no project membership at all.
|
||||
"""
|
||||
other = Project.objects.create(
|
||||
name="Attacker's Project",
|
||||
identifier="ATK",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=other, member=create_user, workspace=workspace, role=20
|
||||
)
|
||||
return other
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def foreign_project(db, workspace):
|
||||
"""A project owned by someone else; the token holder is NOT a member."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
owner = User.objects.create(
|
||||
email=f"owner-{unique_id}@plane.so",
|
||||
username=f"owner_{unique_id}",
|
||||
)
|
||||
owner.set_password("test-password")
|
||||
owner.save()
|
||||
project = Project.objects.create(
|
||||
name="Foreign Project",
|
||||
identifier="FOR",
|
||||
workspace=workspace,
|
||||
created_by=owner,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=owner, workspace=workspace, role=20
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectMemberRosterScope:
|
||||
"""The token holder (``create_user``) is a workspace member but not in the project."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_project_member_cannot_list_roster(
|
||||
self, api_key_client, workspace, attacker_membership, foreign_project
|
||||
):
|
||||
response = api_key_client.get(members_url(workspace.slug, foreign_project.id))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_member_can_list_roster(self, api_key_client, workspace, create_user):
|
||||
"""Positive control: an active project member still reads the roster."""
|
||||
project = Project.objects.create(
|
||||
name="Own Project",
|
||||
identifier="OWN",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20
|
||||
)
|
||||
response = api_key_client.get(members_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
returned = {str(row["id"]) for row in response.data}
|
||||
assert str(create_user.id) in returned
|
||||
261
apps/api/plane/tests/contract/api/test_projects.py
Normal file
261
apps/api/plane/tests/contract/api/test_projects.py
Normal file
@@ -0,0 +1,261 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Project, ProjectMember, State, User, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_workspace_member(db, workspace):
|
||||
"""Create another user that is a member of the workspace, distinct from the creator."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
other = User.objects.create(
|
||||
email=f"other-{unique_id}@plane.so",
|
||||
username=f"other_user_{unique_id}",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
)
|
||||
other.set_password("test-password")
|
||||
other.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=other, role=20)
|
||||
return other
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_user(db):
|
||||
"""Create a user that is NOT a member of any workspace under test."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
outsider = User.objects.create(
|
||||
email=f"outsider-{unique_id}@plane.so",
|
||||
username=f"outsider_{unique_id}",
|
||||
first_name="Out",
|
||||
last_name="Sider",
|
||||
)
|
||||
outsider.set_password("test-password")
|
||||
outsider.save()
|
||||
return outsider
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectListCreateAPIEndpoint:
|
||||
"""Contract tests for POST /api/v1/workspaces/{slug}/projects/."""
|
||||
|
||||
def get_url(self, workspace_slug):
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_lead_as_creator(self, api_key_client, workspace, create_user):
|
||||
"""Regression for the ghost-create bug.
|
||||
|
||||
When project_lead points to the creator's own user_id, the endpoint
|
||||
must return 201 and create a fully-populated project (single
|
||||
ProjectMember as admin, default workflow states).
|
||||
|
||||
Before the fix, the endpoint returned 400 "Please provide valid detail"
|
||||
but had already persisted the Project row without states or members,
|
||||
leaving an unusable orphan.
|
||||
"""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Self Lead Project",
|
||||
"identifier": "SL",
|
||||
"project_lead": str(create_user.id),
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
|
||||
# Look up the project we just created instead of relying on
|
||||
# ordering-sensitive Project.objects.first().
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
# Creator is registered as admin (single membership; lead == creator
|
||||
# should not produce a duplicate row).
|
||||
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
|
||||
# Default workflow states must be created.
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_lead_as_other_user(
|
||||
self, api_key_client, workspace, create_user, other_workspace_member
|
||||
):
|
||||
"""When project_lead is a different workspace member, both creator
|
||||
and lead become admins of the project."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Other Lead Project",
|
||||
"identifier": "OL",
|
||||
"project_lead": str(other_workspace_member.id),
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
|
||||
# Both creator and other_workspace_member are admins.
|
||||
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).exists()
|
||||
assert ProjectMember.objects.filter(project=project, member=other_workspace_member, role=20).exists()
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_without_lead(self, api_key_client, workspace, create_user):
|
||||
"""Baseline regression: omitting project_lead must succeed and the
|
||||
creator becomes the sole admin."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Basic Project",
|
||||
"identifier": "BP",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_lead_not_in_workspace_returns_400(self, api_key_client, workspace, outsider_user):
|
||||
"""When project_lead refers to a user that is NOT a member of the
|
||||
target workspace, the endpoint must reject the request with a 400
|
||||
carrying a field-shaped error and must not persist the Project."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Outsider Lead Project",
|
||||
"identifier": "OUT",
|
||||
"project_lead": str(outsider_user.id),
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST, f"Got {response.status_code}: {response.data!r}"
|
||||
assert "project_lead" in response.data, (
|
||||
f"Expected field-shaped error under 'project_lead', got {response.data!r}"
|
||||
)
|
||||
# No project should have been persisted.
|
||||
assert Project.objects.count() == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_model_activity_not_called_on_rollback(self, api_key_client, workspace, create_user):
|
||||
"""If anything inside the transaction.atomic() block raises, the
|
||||
whole creation must roll back (no Project, no ProjectMember, no
|
||||
State) and the deferred model_activity.delay() task must not fire,
|
||||
because it is registered with transaction.on_commit().
|
||||
|
||||
Force the failure inside State.objects.bulk_create — past the point
|
||||
where the original ghost-create bug would have committed a partial
|
||||
Project — and verify the response is 500 with no side effects.
|
||||
"""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Rollback Probe",
|
||||
"identifier": "RB",
|
||||
"project_lead": str(create_user.id),
|
||||
}
|
||||
|
||||
forced_error = RuntimeError("forced failure for rollback test")
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"plane.api.views.project.State.objects.bulk_create",
|
||||
side_effect=forced_error,
|
||||
),
|
||||
mock.patch("plane.api.views.project.model_activity") as mocked_activity,
|
||||
):
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, (
|
||||
f"Got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
# Transaction must have rolled back: no Project, no ProjectMember,
|
||||
# no State persisted.
|
||||
assert Project.objects.count() == 0
|
||||
assert ProjectMember.objects.count() == 0
|
||||
assert State.objects.count() == 0
|
||||
# And the deferred Celery task must not have been dispatched —
|
||||
# transaction.on_commit() callbacks only fire on a successful commit.
|
||||
mocked_activity.delay.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_invalid_order_by_does_not_500(self, api_key_client, workspace, create_user):
|
||||
"""Regression for GHSA-p885-6jpg-cr2p (DoS half).
|
||||
|
||||
An unknown ``order_by`` field used to reach Django's ``.order_by()``
|
||||
raw and raise a ``FieldError`` → HTTP 500. After the fix the value is
|
||||
sanitized to the safe default and the endpoint returns 200.
|
||||
"""
|
||||
Project.objects.create(
|
||||
name="Ordered Project",
|
||||
identifier="OP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(project=Project.objects.get(identifier="OP"), member=create_user, role=20)
|
||||
|
||||
url = self.get_url(workspace.slug)
|
||||
response = api_key_client.get(url, {"order_by": "not_a_field"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_relational_order_by_injection_does_not_500(self, api_key_client, workspace, create_user):
|
||||
"""Regression for GHSA-p885-6jpg-cr2p (relational-traversal leak half).
|
||||
|
||||
Ordering by a related-table column (``created_by__password``) used to
|
||||
reach ``.order_by()`` raw, forming a blind ordering oracle. After the
|
||||
fix the value is not in ``PROJECT_ORDER_BY_ALLOWLIST`` and is replaced
|
||||
with the safe default, so it can no longer influence SQL ordering.
|
||||
(That the payload maps to the default is asserted deterministically in
|
||||
tests/unit/utils/test_order_by_sanitize.py.)
|
||||
"""
|
||||
project = Project.objects.create(
|
||||
name="Oracle Project",
|
||||
identifier="OR",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20)
|
||||
|
||||
url = self.get_url(workspace.slug)
|
||||
response = api_key_client.get(url, {"order_by": "created_by__password"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_response_still_201_when_broker_dispatch_fails(self, api_key_client, workspace, create_user):
|
||||
"""If model_activity.delay raises *after* the atomic block has
|
||||
committed (e.g., the Celery broker is down), the project, member
|
||||
rows and states are already persisted — the response must remain
|
||||
201 and the failure must be absorbed by Django's robust=True
|
||||
on_commit handling, not surface as a 500.
|
||||
|
||||
Uses ``transaction=True`` so the surrounding test transaction is
|
||||
actually committed and the ``on_commit`` callback fires (the
|
||||
default ``django_db`` wrapper would suppress it via rollback)."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Broker Down",
|
||||
"identifier": "BD",
|
||||
"project_lead": str(create_user.id),
|
||||
}
|
||||
|
||||
with mock.patch("plane.api.views.project.model_activity") as mocked_activity:
|
||||
mocked_activity.delay.side_effect = RuntimeError("broker unavailable")
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
# Project and its scaffolding are persisted (commit happened
|
||||
# before the on_commit callback fired).
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
assert ProjectMember.objects.filter(project=project).count() == 1
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
# The dispatch was attempted but its failure was swallowed by
|
||||
# transaction.on_commit(robust=True).
|
||||
mocked_activity.delay.assert_called_once()
|
||||
103
apps/api/plane/tests/contract/api/test_projects_lite.py
Normal file
103
apps/api/plane/tests/contract/api/test_projects_lite.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for the projects-lite endpoint.
|
||||
|
||||
GET /api/v1/workspaces/<slug>/projects-lite/
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Project, ProjectMember
|
||||
|
||||
|
||||
def _url(slug):
|
||||
return f"/api/v1/workspaces/{slug}/projects-lite/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""A non-archived project the requesting user is an active admin of."""
|
||||
project = Project.objects.create(
|
||||
name="Lite Project",
|
||||
identifier="LP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archived_project(db, workspace, create_user):
|
||||
"""An archived project the requesting user is an active admin of."""
|
||||
project = Project.objects.create(
|
||||
name="Archived Project",
|
||||
identifier="ARCH",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
archived_at=timezone.now(),
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectsLite:
|
||||
@pytest.mark.django_db
|
||||
def test_returns_paginated_results(self, api_key_client, workspace, project):
|
||||
response = api_key_client.get(_url(workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
ids = {str(item["id"]) for item in response.data["results"]}
|
||||
assert str(project.id) in ids
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_returns_only_lite_fields(self, api_key_client, workspace, project):
|
||||
response = api_key_client.get(_url(workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
item = response.data["results"][0]
|
||||
# Trimmed shape — archived_at present, heavy computed fields absent.
|
||||
for key in ("id", "identifier", "name", "cover_image_url", "archived_at"):
|
||||
assert key in item
|
||||
assert "total_members" not in item
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_archived_excluded_by_default(self, api_key_client, workspace, project, archived_project):
|
||||
response = api_key_client.get(_url(workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
ids = {str(item["id"]) for item in response.data["results"]}
|
||||
assert str(project.id) in ids
|
||||
assert str(archived_project.id) not in ids
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_include_archived_returns_all(self, api_key_client, workspace, project, archived_project):
|
||||
response = api_key_client.get(_url(workspace.slug), {"include_archived": "true"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
ids = {str(item["id"]) for item in response.data["results"]}
|
||||
assert str(project.id) in ids
|
||||
assert str(archived_project.id) in ids
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_workspace_is_rejected(self, api_key_client, project):
|
||||
response = api_key_client.get(_url("does-not-exist"))
|
||||
assert response.status_code in (
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
4
apps/api/plane/tests/contract/app/__init__.py
Normal file
4
apps/api/plane/tests/contract/app/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
419
apps/api/plane/tests/contract/app/test_api_token.py
Normal file
419
apps/api/plane/tests/contract/app/test_api_token.py
Normal file
@@ -0,0 +1,419 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import APIToken, User
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestApiTokenEndpoint:
|
||||
"""Test cases for ApiTokenEndpoint"""
|
||||
|
||||
# POST /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_success(self, session_client, create_user, api_token_data):
|
||||
"""Test successful API token creation"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert response.data["label"] == api_token_data["label"]
|
||||
assert response.data["description"] == api_token_data["description"]
|
||||
assert response.data["user_type"] == 0 # Human user
|
||||
|
||||
# Verify token was created in database
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.user == create_user
|
||||
assert token.label == api_token_data["label"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_for_bot_user(self, session_client, create_bot_user, api_token_data):
|
||||
"""Test API token creation for bot user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_bot_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["user_type"] == 1 # Bot user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_minimal_data(self, session_client, create_user):
|
||||
"""Test API token creation with minimal data"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, {}, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert len(response.data["label"]) == 32 # UUID hex length
|
||||
assert response.data["description"] == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_with_expiry(self, session_client, create_user):
|
||||
"""Test API token creation with expiry date"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
future_date = timezone.now() + timedelta(days=30)
|
||||
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify expiry date was set
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.expired_at is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
|
||||
"""Test API token creation without authentication"""
|
||||
# Arrange
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = api_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# GET /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_all_api_tokens(self, session_client, create_user):
|
||||
"""Test retrieving all API tokens for user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
|
||||
# Create multiple tokens
|
||||
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
|
||||
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
|
||||
# Create a service token (should be excluded)
|
||||
APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 2 # Only non-service tokens
|
||||
assert all(token["is_service"] is False for token in response.data)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_empty_api_tokens_list(self, session_client, create_user):
|
||||
"""Test retrieving API tokens when none exist"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == []
|
||||
|
||||
# GET /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_specific_api_token(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test retrieving a specific API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
|
||||
assert response.data["label"] == create_api_token_for_user.label
|
||||
assert "token" not in response.data # Token should not be visible in read serializer
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test retrieving a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test retrieving another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"other-{unique_id}@plane.so"
|
||||
unique_username = f"other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# DELETE /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_delete_api_token_success(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test successful API token deletion"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test deleting a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test deleting another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"delete-other-{unique_id}@plane.so"
|
||||
unique_username = f"delete_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=other_token.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_service_api_token_forbidden(self, session_client, create_user):
|
||||
"""Test deleting a service API token (should fail)"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=service_token.pk).exists()
|
||||
|
||||
# PATCH /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_success(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test successful API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {
|
||||
"label": "Updated Token Label",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == update_data["description"]
|
||||
|
||||
# Verify database was updated
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.label == update_data["label"]
|
||||
assert create_api_token_for_user.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_partial_update(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test partial API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_description = create_api_token_for_user.description
|
||||
update_data = {"label": "Only Label Updated"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == original_description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test updating a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
update_data = {"label": "New Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test updating another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"patch-other-{unique_id}@plane.so"
|
||||
unique_username = f"patch_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
update_data = {"label": "Hacked Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify token was not updated
|
||||
other_token.refresh_from_db()
|
||||
assert other_token.label == "Other Token"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_token(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test that token value cannot be modified via PATCH"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_token = create_api_token_for_user.token
|
||||
update_data = {"token": "plane_api_malicious_token_value"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.token == original_token
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_user_type(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test that user_type cannot be modified via PATCH"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {"user_type": 1}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.user_type == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_allowed_rate_limit(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test that allowed_rate_limit cannot be modified via PATCH"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_rate_limit = create_api_token_for_user.allowed_rate_limit
|
||||
update_data = {"allowed_rate_limit": "100000/min"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.allowed_rate_limit == original_rate_limit
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_service_token(self, session_client, create_user):
|
||||
"""Test that service tokens cannot be modified through user token endpoint"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
|
||||
update_data = {"label": "Hacked Service Token"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
service_token.refresh_from_db()
|
||||
assert service_token.label == "Service Token"
|
||||
|
||||
# Authentication tests
|
||||
@pytest.mark.django_db
|
||||
def test_all_endpoints_require_authentication(self, api_client):
|
||||
"""Test that all endpoints require authentication"""
|
||||
# Arrange
|
||||
endpoints = [
|
||||
(reverse("api-tokens"), "get"),
|
||||
(reverse("api-tokens"), "post"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "get"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "patch"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "delete"),
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
for url, method in endpoints:
|
||||
response = getattr(api_client, method)(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
847
apps/api/plane/tests/contract/app/test_authentication.py
Normal file
847
apps/api/plane/tests/contract/app/test_authentication.py
Normal file
@@ -0,0 +1,847 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from django.test import Client
|
||||
from django.core.exceptions import ValidationError
|
||||
from unittest.mock import patch
|
||||
|
||||
from plane.authentication.provider.credentials.magic_code import MagicCodeProvider
|
||||
from plane.authentication.rate_limit import AuthenticationThrottle
|
||||
from plane.db.models import User
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.license.models import Instance
|
||||
|
||||
|
||||
def _clear_auth_throttle_keys():
|
||||
"""Delete only the AuthenticationThrottle history keys from the shared cache.
|
||||
|
||||
DRF's SimpleRateThrottle stores request history under
|
||||
``throttle_<scope>_<ident>`` keys, so scoping the pattern to
|
||||
``throttle_authentication_`` removes just this throttle's entries instead
|
||||
of wiping unrelated cache state.
|
||||
"""
|
||||
cache.delete_pattern("throttle_authentication_*")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_auth_throttle_cache():
|
||||
"""Clear the auth throttle state around every test in this module.
|
||||
|
||||
The auth endpoints apply a per-IP throttle (AuthenticationThrottle) whose
|
||||
request history lives in the Django cache. Because the test session reuses a
|
||||
single cache, that count leaks across tests and trips RATE_LIMIT_EXCEEDED in
|
||||
classes that don't reset it. This mirrors the per-class ``_clear_state``
|
||||
fixtures already used by the throttle / verify-attempt classes, applied
|
||||
module-wide so every auth test starts from a clean throttle state.
|
||||
"""
|
||||
_clear_auth_throttle_keys()
|
||||
yield
|
||||
_clear_auth_throttle_keys()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_instance(db):
|
||||
"""Create and configure an instance for authentication tests"""
|
||||
instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id
|
||||
|
||||
# Create or update instance with all required fields
|
||||
instance, _ = Instance.objects.update_or_create(
|
||||
id=instance_id,
|
||||
defaults={
|
||||
"instance_name": "Test Instance",
|
||||
"instance_id": str(uuid.uuid4()),
|
||||
"current_version": "1.0.0",
|
||||
"domain": "http://localhost:8000",
|
||||
"last_checked_at": timezone.now(),
|
||||
"is_setup_done": True,
|
||||
},
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def django_client():
|
||||
"""Return a Django test client with User-Agent header for handling redirects"""
|
||||
client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_authentication_throttle():
|
||||
"""
|
||||
Reset the shared AuthenticationThrottle bucket around every test in this module.
|
||||
|
||||
All auth endpoints share one per-IP throttle bucket (scope "authentication")
|
||||
and the test client always presents the same IP, so without clearing the cache
|
||||
between tests the request count accumulates across tests and later ones trip
|
||||
RATE_LIMIT_EXCEEDED. Clearing here keeps each test's rate-limit budget isolated.
|
||||
"""
|
||||
cache.clear()
|
||||
yield
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicLinkGenerate:
|
||||
"""Test magic link generation functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for magic link tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, api_client, setup_user, setup_instance):
|
||||
"""Test magic link generation with empty data"""
|
||||
url = reverse("magic-generate")
|
||||
try:
|
||||
response = api_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
except ValidationError:
|
||||
# If a ValidationError is raised directly, that's also acceptable
|
||||
# as it indicates the empty email was rejected
|
||||
assert True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_validity(self, api_client, setup_user, setup_instance):
|
||||
"""Test magic link generation with invalid email format"""
|
||||
url = reverse("magic-generate")
|
||||
try:
|
||||
response = api_client.post(url, {"email": "useremail.com"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "error_code" in response.data # Check for error code in response
|
||||
except ValidationError:
|
||||
# If a ValidationError is raised directly, that's also acceptable
|
||||
# as it indicates the invalid email was rejected
|
||||
assert True
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
"""Test successful magic link generation"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "key" in response.data # Check for key in response
|
||||
|
||||
# Verify the mock was called with the expected arguments
|
||||
mock_magic_link.assert_called_once()
|
||||
args = mock_magic_link.call_args[0]
|
||||
assert args[0] == "user@plane.so" # First arg should be the email
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
"""Test exceeding maximum magic link generation attempts"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
for _ in range(4):
|
||||
api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "error_code" in response.data # Check for error code in response
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestSignInEndpoint:
|
||||
"""Test sign-in functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for authentication tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with empty data"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_validity(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with invalid email format"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {"email": "useremail.com", "password": "user@123"}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_exists(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with non-existent user"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {"email": "user@email.so", "password": "user123"}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_validity(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with incorrect password"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {"email": "user@plane.so", "password": "user123"}, follow=True)
|
||||
|
||||
# Check for the specific authentication error in the URL
|
||||
redirect_urls = [url for url, _ in response.redirect_chain]
|
||||
redirect_contents = " ".join(redirect_urls)
|
||||
|
||||
# The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN
|
||||
assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_login(self, django_client, setup_user, setup_instance):
|
||||
"""Test successful sign-in"""
|
||||
url = reverse("sign-in")
|
||||
|
||||
# First make the request without following redirects
|
||||
response = django_client.post(url, {"email": "user@plane.so", "password": "user@123"}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect (302) without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Now follow just the first redirect to avoid 404s
|
||||
response = django_client.get(response.url, follow=False)
|
||||
|
||||
# The user should be authenticated regardless of the final page
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_next_path_redirection(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with next_path parameter"""
|
||||
url = reverse("sign-in")
|
||||
next_path = "workspaces"
|
||||
|
||||
# First make the request without following redirects
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "password": "user@123", "next_path": next_path},
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect (302) without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# In a real browser, the next_path would be used to build the absolute URL
|
||||
# Since we're just testing the authentication logic, we won't check for the exact URL structure
|
||||
# Instead, just verify that we're authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignIn:
|
||||
"""Test magic link sign-in functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for magic sign-in tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_user, setup_instance):
|
||||
"""Test magic link sign-in with empty data"""
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance):
|
||||
"""Test magic link sign-in with expired/invalid link"""
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False)
|
||||
|
||||
# Check that we get a redirect
|
||||
assert response.status_code == 302
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_does_not_exist(self, django_client, setup_instance):
|
||||
"""Test magic sign-in with non-existent user"""
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
"""Test successful magic link sign-in process"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get("magic_user@plane.so"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {"email": "user@plane.so", "code": token}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
"""Test magic sign-in with next_path parameter"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get("magic_user@plane.so"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects.
|
||||
# next_path must start with "/" per validate_next_path (otherwise it's discarded).
|
||||
url = reverse("magic-sign-in")
|
||||
next_path = "/workspaces"
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": token, "next_path": next_path},
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check that the redirect URL contains the next_path (URL-encoded, leading slash → %2F)
|
||||
assert "workspaces" in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignUp:
|
||||
"""Test magic link sign-up functionality"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_instance):
|
||||
"""Test magic link sign-up with empty data"""
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_already_exists(self, django_client, db, setup_instance):
|
||||
"""Test magic sign-up with existing user"""
|
||||
# Create a user that already exists
|
||||
User.objects.create(email="existing@plane.so")
|
||||
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expired_invalid_magic_link(self, django_client, setup_instance):
|
||||
"""Test magic link sign-up with expired/invalid link"""
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False)
|
||||
|
||||
# Check that we get a redirect
|
||||
assert response.status_code == 302
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
"""Test successful magic link sign-up process"""
|
||||
email = "newuser@plane.so"
|
||||
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": email}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get(f"magic_{email}"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {"email": email, "code": token}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check if user was created
|
||||
assert User.objects.filter(email=email).exists()
|
||||
|
||||
# Check if user is authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
"""Test magic sign-up with next_path parameter"""
|
||||
email = "newuser2@plane.so"
|
||||
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": email}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get(f"magic_{email}"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-up")
|
||||
next_path = "onboarding"
|
||||
response = django_client.post(url, {"email": email, "code": token, "next_path": next_path}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# In a real browser, the next_path would be used to build the absolute URL
|
||||
# Since we're just testing the authentication logic, we won't check for the exact URL structure
|
||||
|
||||
# Check if user was created
|
||||
assert User.objects.filter(email=email).exists()
|
||||
|
||||
# Check if user is authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
def _generate_magic_token(api_client, email):
|
||||
"""Hit /magic-generate/ for `email` and return the token that landed in Redis."""
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": email}, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
ri = redis_instance()
|
||||
return json.loads(ri.get(f"magic_{email}"))["token"]
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignInVerifyAttempts:
|
||||
"""Per-token wrong-code attempt counter and exhaustion behavior (GHSA-9pvm-fcf6-9234)."""
|
||||
|
||||
EMAIL = "verify-attempts@plane.so"
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
user = User.objects.create(email=self.EMAIL)
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_state(self):
|
||||
"""Reset throttle cache and magic-link redis state between tests in this class."""
|
||||
cache.clear()
|
||||
ri = redis_instance()
|
||||
ri.delete(f"magic_{self.EMAIL}")
|
||||
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
|
||||
yield
|
||||
cache.clear()
|
||||
ri.delete(f"magic_{self.EMAIL}")
|
||||
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_exhausted_after_max_wrong_attempts(
|
||||
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
|
||||
):
|
||||
"""
|
||||
After MAX_VERIFY_ATTEMPTS wrong codes the next verify must redirect with
|
||||
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN and both Redis keys must be gone.
|
||||
|
||||
With MAX_VERIFY_ATTEMPTS=5 the 5th wrong attempt itself triggers exhaustion
|
||||
(4 INVALID + 1 EXHAUSTED), matching the >= check in set_user_data.
|
||||
"""
|
||||
_generate_magic_token(api_client, self.EMAIL)
|
||||
url = reverse("magic-sign-in")
|
||||
ri = redis_instance()
|
||||
|
||||
# First (MAX-1) wrong attempts: each redirects with INVALID_MAGIC_CODE_SIGN_IN.
|
||||
for i in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1):
|
||||
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert response.status_code == 302, f"attempt {i+1} unexpected status"
|
||||
assert "INVALID_MAGIC_CODE_SIGN_IN" in response.url, f"attempt {i+1} did not return INVALID"
|
||||
|
||||
# Token and counter both still live, with counter at MAX-1.
|
||||
assert ri.exists(f"magic_{self.EMAIL}")
|
||||
assert int(ri.get(f"magic_{self.EMAIL}:verify_attempts")) == MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1
|
||||
|
||||
# The MAX-th wrong attempt is the exhausting one.
|
||||
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert response.status_code == 302
|
||||
assert "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN" in response.url
|
||||
|
||||
# Both the token and the counter must be deleted.
|
||||
assert not ri.exists(f"magic_{self.EMAIL}")
|
||||
assert not ri.exists(f"magic_{self.EMAIL}:verify_attempts")
|
||||
|
||||
# Follow-up verify now sees the key as missing and reports EXPIRED.
|
||||
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert response.status_code == 302
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_counter_increments_on_each_wrong_attempt(
|
||||
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
|
||||
):
|
||||
"""The verify_attempts counter increments by exactly one per wrong-code POST."""
|
||||
_generate_magic_token(api_client, self.EMAIL)
|
||||
url = reverse("magic-sign-in")
|
||||
ri = redis_instance()
|
||||
counter_key = f"magic_{self.EMAIL}:verify_attempts"
|
||||
|
||||
# Before any wrong attempt the counter does not exist (Lua INCR creates it).
|
||||
assert not ri.exists(counter_key)
|
||||
|
||||
for expected in range(1, MagicCodeProvider.MAX_VERIFY_ATTEMPTS):
|
||||
django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert int(ri.get(counter_key)) == expected, f"counter mismatch after {expected} attempts"
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_counter_resets_on_token_regeneration(
|
||||
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
|
||||
):
|
||||
"""
|
||||
Regenerating the magic-link must reset the verify-attempt counter so the
|
||||
user isn't pre-locked-out by a previous session's wrong attempts.
|
||||
"""
|
||||
_generate_magic_token(api_client, self.EMAIL)
|
||||
url = reverse("magic-sign-in")
|
||||
ri = redis_instance()
|
||||
counter_key = f"magic_{self.EMAIL}:verify_attempts"
|
||||
|
||||
for _ in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 2):
|
||||
django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert int(ri.get(counter_key)) == MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 2
|
||||
|
||||
# Regenerate the magic-link — the counter should be cleared.
|
||||
_generate_magic_token(api_client, self.EMAIL)
|
||||
assert not ri.exists(counter_key)
|
||||
|
||||
# Fresh wrong attempt now produces INVALID (not EXHAUSTED) and counter starts at 1.
|
||||
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert "INVALID_MAGIC_CODE_SIGN_IN" in response.url
|
||||
assert int(ri.get(counter_key)) == 1
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignUpVerifyAttempts:
|
||||
"""Sign-up flow gets the same per-token attempt cap (no existing User row)."""
|
||||
|
||||
EMAIL = "signup-verify-attempts@plane.so"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_state(self):
|
||||
cache.clear()
|
||||
ri = redis_instance()
|
||||
ri.delete(f"magic_{self.EMAIL}")
|
||||
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
|
||||
yield
|
||||
cache.clear()
|
||||
ri.delete(f"magic_{self.EMAIL}")
|
||||
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_signup_exhausted_after_max_wrong_attempts(
|
||||
self, mock_magic_link, django_client, api_client, setup_instance
|
||||
):
|
||||
"""The MAX-th wrong code on the sign-up endpoint returns the SIGN_UP variant of EXHAUSTED."""
|
||||
_generate_magic_token(api_client, self.EMAIL)
|
||||
url = reverse("magic-sign-up")
|
||||
ri = redis_instance()
|
||||
|
||||
for _ in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1):
|
||||
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert "INVALID_MAGIC_CODE_SIGN_UP" in response.url
|
||||
|
||||
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
|
||||
assert "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP" in response.url
|
||||
assert not ri.exists(f"magic_{self.EMAIL}")
|
||||
assert not ri.exists(f"magic_{self.EMAIL}:verify_attempts")
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestAuthenticationThrottle:
|
||||
"""Per-IP throttle on the redirect-flow magic-link and password endpoints."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_state(self):
|
||||
cache.clear()
|
||||
yield
|
||||
cache.clear()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_magic_sign_in_throttled(self, django_client, setup_instance):
|
||||
"""Posting past the configured rate from one IP returns RATE_LIMIT_EXCEEDED."""
|
||||
url = reverse("magic-sign-in")
|
||||
# Drop the rate so the test doesn't have to fire 10+ requests.
|
||||
with patch.object(AuthenticationThrottle, "rate", "2/minute"):
|
||||
for _ in range(2):
|
||||
response = django_client.post(url, {"email": "throttle@plane.so", "code": "000000"}, follow=False)
|
||||
assert response.status_code == 302
|
||||
assert "RATE_LIMIT_EXCEEDED" not in response.url
|
||||
|
||||
# The 3rd request from the same IP within the window trips the throttle.
|
||||
response = django_client.post(url, {"email": "throttle@plane.so", "code": "000000"}, follow=False)
|
||||
assert response.status_code == 302
|
||||
assert "RATE_LIMIT_EXCEEDED" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_magic_sign_up_throttled(self, django_client, setup_instance):
|
||||
"""The sign-up sibling shares the same scope and trips on the same per-IP budget."""
|
||||
url = reverse("magic-sign-up")
|
||||
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
|
||||
response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" not in response.url
|
||||
|
||||
response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_sign_in_throttled(self, django_client, setup_instance):
|
||||
"""The password sign-in endpoint is throttled per IP on the same scope."""
|
||||
url = reverse("sign-in")
|
||||
with patch.object(AuthenticationThrottle, "rate", "2/minute"):
|
||||
for _ in range(2):
|
||||
response = django_client.post(
|
||||
url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert "RATE_LIMIT_EXCEEDED" not in response.url
|
||||
|
||||
# The 3rd request from the same IP within the window trips the throttle.
|
||||
response = django_client.post(url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False)
|
||||
assert response.status_code == 302
|
||||
assert "RATE_LIMIT_EXCEEDED" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_sign_up_throttled(self, django_client, setup_instance):
|
||||
"""The password sign-up endpoint trips on the same per-IP budget."""
|
||||
url = reverse("sign-up")
|
||||
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
|
||||
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" not in response.url
|
||||
|
||||
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_space_password_sign_in_throttled(self, django_client, setup_instance):
|
||||
"""The spaces password sign-in endpoint is throttled per IP."""
|
||||
url = reverse("space-sign-in")
|
||||
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
|
||||
response = django_client.post(url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" not in response.url
|
||||
|
||||
response = django_client.post(url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_space_password_sign_up_throttled(self, django_client, setup_instance):
|
||||
"""The spaces password sign-up endpoint trips on the same per-IP budget."""
|
||||
url = reverse("space-sign-up")
|
||||
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
|
||||
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" not in response.url
|
||||
|
||||
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
|
||||
assert "RATE_LIMIT_EXCEEDED" in response.url
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestBotUserLoginBlocked:
|
||||
"""Bot service accounts (is_bot=True) must never authenticate through the
|
||||
interactive login flow.
|
||||
|
||||
Bots are internal identities (e.g. the WORKSPACE_SEED bot) that act only via
|
||||
API tokens. Every interactive provider funnels through
|
||||
Adapter.complete_login_or_signup(), which rejects bots with
|
||||
BOT_USER_LOGIN_FORBIDDEN (5017). These are regression guards for that block.
|
||||
"""
|
||||
|
||||
BOT_EMAIL = "bot-login@plane.so"
|
||||
HUMAN_EMAIL = "human-login@plane.so"
|
||||
PASSWORD = "Str0ng-Pass!42"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_state(self):
|
||||
"""Reset throttle cache and the bot's magic-link redis state around each test."""
|
||||
cache.clear()
|
||||
ri = redis_instance()
|
||||
ri.delete(f"magic_{self.BOT_EMAIL}")
|
||||
ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts")
|
||||
yield
|
||||
cache.clear()
|
||||
ri.delete(f"magic_{self.BOT_EMAIL}")
|
||||
ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts")
|
||||
|
||||
@pytest.fixture
|
||||
def bot_user(self, db):
|
||||
"""An active bot account with a known password so the credential check
|
||||
passes and execution reaches the login chokepoint."""
|
||||
user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True)
|
||||
user.set_password(self.PASSWORD)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def human_user(self, db):
|
||||
"""A normal (non-bot) account, identical apart from is_bot, as a control."""
|
||||
user = User.objects.create(email=self.HUMAN_EMAIL, is_active=True)
|
||||
user.set_password(self.PASSWORD)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bot_password_sign_in_blocked(self, django_client, bot_user, setup_instance):
|
||||
"""Password sign-in with a bot's *correct* credentials is still rejected:
|
||||
the block happens after credential verification, so no session is created."""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(
|
||||
url, {"email": self.BOT_EMAIL, "password": self.PASSWORD}, follow=False
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert "BOT_USER_LOGIN_FORBIDDEN" in response.url
|
||||
# The block must prevent authentication.
|
||||
assert "_auth_user_id" not in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_bot_magic_sign_in_blocked(
|
||||
self, mock_magic_link, django_client, api_client, bot_user, setup_instance
|
||||
):
|
||||
"""The same block applies via a second provider (magic code), proving the
|
||||
guard sits at the shared chokepoint rather than in one provider."""
|
||||
token = _generate_magic_token(api_client, self.BOT_EMAIL)
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {"email": self.BOT_EMAIL, "code": token}, follow=False)
|
||||
assert response.status_code == 302
|
||||
assert "BOT_USER_LOGIN_FORBIDDEN" in response.url
|
||||
assert "_auth_user_id" not in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_human_password_sign_in_allowed(self, django_client, human_user, setup_instance):
|
||||
"""Control: a normal user with the identical setup still signs in — the
|
||||
guard is scoped strictly to is_bot and does not regress human logins."""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(
|
||||
url, {"email": self.HUMAN_EMAIL, "password": self.PASSWORD}, follow=False
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert "BOT_USER_LOGIN_FORBIDDEN" not in response.url
|
||||
assert "error_code" not in response.url
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestBotUserAdminSignInBlocked:
|
||||
"""A bot must not sign in to the instance-admin console either.
|
||||
|
||||
InstanceAdminSignInEndpoint mints its own session via user_login() outside
|
||||
Adapter.complete_login_or_signup(), so it carries an independent is_bot
|
||||
guard that rejects bots with ADMIN_AUTHENTICATION_FAILED before the admin
|
||||
membership check. (Uses the literal path because license/urls.py reuses the
|
||||
name "instance-admin-sign-in" for both sign-in and sign-up.)
|
||||
"""
|
||||
|
||||
ADMIN_SIGN_IN_PATH = "/api/instances/admins/sign-in/"
|
||||
BOT_EMAIL = "admin-bot@plane.so"
|
||||
PASSWORD = "Str0ng-Pass!42"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_state(self):
|
||||
cache.clear()
|
||||
yield
|
||||
cache.clear()
|
||||
|
||||
@pytest.fixture
|
||||
def bot_user(self, db):
|
||||
user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True)
|
||||
user.set_password(self.PASSWORD)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bot_admin_sign_in_blocked(self, django_client, bot_user, setup_instance):
|
||||
"""A bot is rejected at the admin sign-in endpoint and no session is created,
|
||||
even though it is active and the password is correct."""
|
||||
response = django_client.post(
|
||||
self.ADMIN_SIGN_IN_PATH,
|
||||
{"email": self.BOT_EMAIL, "password": self.PASSWORD},
|
||||
follow=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert "ADMIN_AUTHENTICATION_FAILED" in response.url
|
||||
assert "_auth_user_id" not in django_client.session
|
||||
213
apps/api/plane/tests/contract/app/test_avatar_url_annotation.py
Normal file
213
apps/api/plane/tests/contract/app/test_avatar_url_annotation.py
Normal file
@@ -0,0 +1,213 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Regression tests for the ``avatar_url`` annotation used by the assignee
|
||||
distribution / analytics querysets.
|
||||
|
||||
Those querysets build the avatar URL with
|
||||
``Concat(Value("/api/assets/v2/static/"), <user>__avatar_asset, Value("/"))``.
|
||||
On Django 5.x ``ConcatPair.as_postgresql`` resolves the output field of each
|
||||
argument, so concatenating a ``CharField`` with the raw ``UUIDField`` column
|
||||
raises ``FieldError: Expression contains mixed types``. Every endpoint below
|
||||
returned a 500 until the UUID column was explicitly cast to text.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
FileAsset,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_with_avatar_asset(db, workspace, create_user):
|
||||
"""A user whose avatar comes from a FileAsset, i.e. the branch of the
|
||||
``Case`` expression that concatenates the asset UUID into a URL."""
|
||||
asset = FileAsset.objects.create(
|
||||
workspace=workspace,
|
||||
asset=f"{workspace.id}/avatar.png",
|
||||
size=1024,
|
||||
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
|
||||
user=create_user,
|
||||
is_uploaded=True,
|
||||
)
|
||||
create_user.avatar_asset = asset
|
||||
create_user.save()
|
||||
return create_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, user_with_avatar_asset):
|
||||
project = Project.objects.create(
|
||||
name="Avatar URL Project",
|
||||
identifier="AVU",
|
||||
workspace=workspace,
|
||||
created_by=user_with_avatar_asset,
|
||||
module_view=True,
|
||||
cycle_view=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
member=user_with_avatar_asset,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assigned_issue(db, project, user_with_avatar_asset):
|
||||
"""A work item assigned to the avatar-bearing user, so the distribution
|
||||
querysets have at least one row to annotate."""
|
||||
state = State.objects.create(name="Todo", group="unstarted", project=project, workspace=project.workspace)
|
||||
issue = Issue.objects.create(
|
||||
name="Avatar URL Issue",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
state=state,
|
||||
created_by=user_with_avatar_asset,
|
||||
)
|
||||
IssueAssignee.objects.create(
|
||||
issue=issue,
|
||||
assignee=user_with_avatar_asset,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
)
|
||||
return issue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module(db, project, assigned_issue):
|
||||
module = Module.objects.create(name="Avatar URL Module", project=project, workspace=project.workspace)
|
||||
ModuleIssue.objects.create(issue=assigned_issue, module=module, project=project, workspace=project.workspace)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archived_module(db, project, assigned_issue):
|
||||
module = Module.objects.create(
|
||||
name="Archived Avatar URL Module",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
archived_at=timezone.now(),
|
||||
)
|
||||
ModuleIssue.objects.create(issue=assigned_issue, module=module, project=project, workspace=project.workspace)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycle(db, project, assigned_issue, user_with_avatar_asset):
|
||||
cycle = Cycle.objects.create(
|
||||
name="Avatar URL Cycle",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
owned_by=user_with_avatar_asset,
|
||||
start_date=timezone.now(),
|
||||
end_date=timezone.now() + timezone.timedelta(days=7),
|
||||
)
|
||||
CycleIssue.objects.create(issue=assigned_issue, cycle=cycle, project=project, workspace=project.workspace)
|
||||
return cycle
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archived_cycle(db, project, user_with_avatar_asset):
|
||||
return Cycle.objects.create(
|
||||
name="Archived Avatar URL Cycle",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
owned_by=user_with_avatar_asset,
|
||||
archived_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestAvatarUrlAnnotation:
|
||||
@pytest.mark.django_db
|
||||
def test_module_retrieve_builds_avatar_url_from_asset(
|
||||
self, session_client, workspace, project, module, user_with_avatar_asset
|
||||
):
|
||||
response = session_client.get(f"/api/workspaces/{workspace.slug}/projects/{project.id}/modules/{module.id}/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
assignees = list(response.data["distribution"]["assignees"])
|
||||
assert [a["avatar_url"] for a in assignees] == [
|
||||
f"/api/assets/v2/static/{user_with_avatar_asset.avatar_asset_id}/"
|
||||
]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_archived_module_retrieve(self, session_client, workspace, project, archived_module):
|
||||
response = session_client.get(
|
||||
f"/api/workspaces/{workspace.slug}/projects/{project.id}/archived-modules/{archived_module.id}/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_archived_cycle_retrieve(self, session_client, workspace, project, archived_cycle):
|
||||
response = session_client.get(
|
||||
f"/api/workspaces/{workspace.slug}/projects/{project.id}/archived-cycles/{archived_cycle.id}/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cycle_analytics(self, session_client, workspace, project, cycle):
|
||||
response = session_client.get(
|
||||
f"/api/workspaces/{workspace.slug}/projects/{project.id}/cycles/{cycle.id}/analytics/?type=issues"
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workspace_analytics(self, session_client, workspace, project, assigned_issue):
|
||||
response = session_client.get(
|
||||
f"/api/workspaces/{workspace.slug}/analytics/?x_axis=assignees__id&y_axis=issue_count"
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_default_analytics(self, session_client, workspace, project, assigned_issue):
|
||||
response = session_client.get(f"/api/workspaces/{workspace.slug}/default-analytics/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_advance_analytics_stats(self, session_client, workspace, project, assigned_issue):
|
||||
response = session_client.get(
|
||||
f"/api/workspaces/{workspace.slug}/projects/{project.id}/advance-analytics-stats/?type=work-items"
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_entity_search_user_mention(self, session_client, workspace, project):
|
||||
response = session_client.get(
|
||||
f"/api/workspaces/{workspace.slug}/entity-search/"
|
||||
f"?query_type=user_mention&query=Test&project_id={project.id}"
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cycle_transfer_issues(self, session_client, workspace, project, cycle, user_with_avatar_asset):
|
||||
new_cycle = Cycle.objects.create(
|
||||
name="Transfer Target Cycle",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
owned_by=user_with_avatar_asset,
|
||||
start_date=timezone.now() + timezone.timedelta(days=8),
|
||||
end_date=timezone.now() + timezone.timedelta(days=14),
|
||||
)
|
||||
response = session_client.post(
|
||||
f"/api/workspaces/{workspace.slug}/projects/{project.id}/cycles/{cycle.id}/transfer-issues/",
|
||||
{"new_cycle_id": str(new_cycle.id)},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
176
apps/api/plane/tests/contract/app/test_cycle_issue_app.py
Normal file
176
apps/api/plane/tests/contract/app/test_cycle_issue_app.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Regression test for GHSA-4w5x-wc9w-f47x.
|
||||
|
||||
CycleIssueViewSet.create reassigned any CycleIssue row matched by issue_id to
|
||||
the caller's cycle without scoping the lookup to the caller's
|
||||
workspace/project. An ADMIN/MEMBER of their own project could therefore pass a
|
||||
work-item UUID from a *different* tenant and silently evict the victim's work
|
||||
item from the victim's cycle (cross-tenant write / BOLA).
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attacker_project(db, workspace, create_user):
|
||||
"""Project + cycle in the attacker's own workspace; attacker is admin."""
|
||||
project = Project.objects.create(
|
||||
name="Attacker Project",
|
||||
identifier="ATK",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attacker_cycle(db, workspace, attacker_project, create_user):
|
||||
return Cycle.objects.create(
|
||||
name="Attacker Cycle",
|
||||
project=attacker_project,
|
||||
workspace=workspace,
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_tenant(db):
|
||||
"""A completely separate workspace/project/cycle owning a work item that is
|
||||
already assigned to the victim's own cycle."""
|
||||
uid = uuid4().hex[:8]
|
||||
victim_user = User.objects.create(
|
||||
email=f"victim-{uid}@plane.so",
|
||||
username=f"victim_{uid}",
|
||||
first_name="Victim",
|
||||
last_name="User",
|
||||
)
|
||||
victim_ws = Workspace.objects.create(name="Victim WS", owner=victim_user, slug=f"victim-{uid}")
|
||||
WorkspaceMember.objects.create(workspace=victim_ws, member=victim_user, role=20)
|
||||
victim_project = Project.objects.create(
|
||||
name="Victim Project",
|
||||
identifier="VIC",
|
||||
workspace=victim_ws,
|
||||
created_by=victim_user,
|
||||
)
|
||||
ProjectMember.objects.create(project=victim_project, member=victim_user, role=20, is_active=True)
|
||||
state = State.objects.create(
|
||||
name="Todo", project=victim_project, workspace=victim_ws, group="backlog", default=True
|
||||
)
|
||||
victim_issue = Issue.objects.create(
|
||||
name="Victim Issue",
|
||||
workspace=victim_ws,
|
||||
project=victim_project,
|
||||
state=state,
|
||||
created_by=victim_user,
|
||||
)
|
||||
victim_cycle = Cycle.objects.create(
|
||||
name="Victim Cycle", project=victim_project, workspace=victim_ws, owned_by=victim_user
|
||||
)
|
||||
cycle_issue = CycleIssue.objects.create(
|
||||
issue=victim_issue,
|
||||
cycle=victim_cycle,
|
||||
project=victim_project,
|
||||
workspace=victim_ws,
|
||||
created_by=victim_user,
|
||||
)
|
||||
return {
|
||||
"issue": victim_issue,
|
||||
"cycle": victim_cycle,
|
||||
"cycle_issue": cycle_issue,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestCycleIssueCrossTenantBOLA:
|
||||
def get_url(self, workspace_slug, project_id, cycle_id):
|
||||
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_foreign_tenant_cycle_issue_not_reassigned(
|
||||
self, session_client, workspace, attacker_project, attacker_cycle, victim_tenant
|
||||
):
|
||||
"""The attacker adds a foreign-tenant work-item UUID to their own cycle.
|
||||
|
||||
Before the fix the victim's CycleIssue row was reassigned to the
|
||||
attacker's cycle (cycle_id flipped). After the fix the foreign row is
|
||||
excluded from the lookup, so it stays in the victim's cycle.
|
||||
"""
|
||||
victim_issue = victim_tenant["issue"]
|
||||
victim_cycle = victim_tenant["cycle"]
|
||||
victim_cycle_issue = victim_tenant["cycle_issue"]
|
||||
|
||||
url = self.get_url(workspace.slug, attacker_project.id, attacker_cycle.id)
|
||||
response = session_client.post(url, {"issues": [str(victim_issue.id)]}, format="json")
|
||||
|
||||
# The endpoint reports success regardless; the security property is that
|
||||
# the foreign row is untouched.
|
||||
assert response.status_code in (status.HTTP_201_CREATED, status.HTTP_200_OK), (
|
||||
f"Got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
|
||||
victim_cycle_issue.refresh_from_db()
|
||||
assert victim_cycle_issue.cycle_id == victim_cycle.id, (
|
||||
"Cross-tenant reassignment: victim's CycleIssue was moved to the attacker's cycle"
|
||||
)
|
||||
# No CycleIssue for the victim's issue should exist under the attacker's cycle.
|
||||
assert not CycleIssue.objects.filter(
|
||||
cycle_id=attacker_cycle.id, issue_id=victim_issue.id
|
||||
).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_same_tenant_reassignment_still_works(
|
||||
self, session_client, workspace, attacker_project, attacker_cycle, create_user
|
||||
):
|
||||
"""A legitimate reassignment within the caller's own project must still
|
||||
move the issue into the target cycle — the scope guard must not break
|
||||
the normal flow."""
|
||||
state = State.objects.create(
|
||||
name="Todo", project=attacker_project, workspace=workspace, group="backlog", default=True
|
||||
)
|
||||
own_issue = Issue.objects.create(
|
||||
name="Own Issue",
|
||||
workspace=workspace,
|
||||
project=attacker_project,
|
||||
state=state,
|
||||
created_by=create_user,
|
||||
)
|
||||
old_cycle = Cycle.objects.create(
|
||||
name="Old Cycle", project=attacker_project, workspace=workspace, owned_by=create_user
|
||||
)
|
||||
own_cycle_issue = CycleIssue.objects.create(
|
||||
issue=own_issue,
|
||||
cycle=old_cycle,
|
||||
project=attacker_project,
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
url = self.get_url(workspace.slug, attacker_project.id, attacker_cycle.id)
|
||||
response = session_client.post(url, {"issues": [str(own_issue.id)]}, format="json")
|
||||
|
||||
assert response.status_code in (status.HTTP_201_CREATED, status.HTTP_200_OK), (
|
||||
f"Got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
own_cycle_issue.refresh_from_db()
|
||||
assert own_cycle_issue.cycle_id == attacker_cycle.id, (
|
||||
"Legitimate same-project reassignment must still move the issue to the target cycle"
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for ``DeployBoardViewSet`` authorization.
|
||||
|
||||
Regression coverage for the app-side sibling of GHSA-w2vf-m9x9-mvmc (WEB-8075).
|
||||
``DeployBoardViewSet`` uses ``ProjectMemberPermission`` whose SAFE_METHODS branch
|
||||
previously checked only workspace membership, so a workspace member who was NOT
|
||||
a member of a project could ``GET .../project-deploy-boards/`` and read that
|
||||
project's publish configuration.
|
||||
|
||||
The fix scopes the SAFE_METHODS check to ``project_id=view.project_id`` so a
|
||||
non-member is rejected with 403.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import Project, ProjectMember, User, WorkspaceMember
|
||||
|
||||
|
||||
def deploy_board_url(slug, project_id):
|
||||
return f"/api/workspaces/{slug}/projects/{project_id}/project-deploy-boards/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""A project; ``create_user`` (session_client) is an active member."""
|
||||
project = Project.objects.create(
|
||||
name="Board Project",
|
||||
identifier="BP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_client(db, workspace, create_user):
|
||||
"""Session client for a workspace member who is NOT in ``project``.
|
||||
|
||||
The outsider is made a member of an *unrelated* project so the vulnerable
|
||||
SAFE_METHODS check (ProjectMember filtered by workspace only, no project_id)
|
||||
would pass; without that the request is denied for simply having no project
|
||||
membership, not for the cross-project scoping being fixed here.
|
||||
"""
|
||||
unique_id = uuid4().hex[:8]
|
||||
outsider = User.objects.create(
|
||||
email=f"outsider-{unique_id}@plane.so",
|
||||
username=f"outsider_{unique_id}",
|
||||
)
|
||||
outsider.set_password("test-password")
|
||||
outsider.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=outsider, role=15)
|
||||
other_project = Project.objects.create(
|
||||
name="Outsider's Project",
|
||||
identifier="OP",
|
||||
workspace=workspace,
|
||||
created_by=outsider,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=other_project, member=outsider, workspace=workspace, role=15
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=outsider)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestDeployBoardProjectScope:
|
||||
@pytest.mark.django_db
|
||||
def test_non_project_member_cannot_read_deploy_board(self, outsider_client, workspace, project):
|
||||
response = outsider_client.get(deploy_board_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_member_can_read_deploy_board(self, session_client, workspace, project):
|
||||
"""Positive control: an active project member is not blocked."""
|
||||
response = session_client.get(deploy_board_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for ``IssueListEndpoint`` guest scoping.
|
||||
|
||||
Regression coverage for GHSA-32c7-84jc-4w67 (WEB-8074). ``IssueListEndpoint.get``
|
||||
(``/workspaces/<slug>/projects/<project_id>/issues/list/``) returned any issue
|
||||
whose id was passed in ``?issues=``, without applying the guest ``created_by``
|
||||
restriction that its sibling ``IssueViewSet.list`` enforces. A project GUEST on a
|
||||
project with ``guest_view_all_features=False`` could therefore read issues they
|
||||
did not author by supplying their ids.
|
||||
|
||||
The fix scopes the queryset to ``created_by=request.user`` for such guests,
|
||||
mirroring ``IssueViewSet.list``.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/list/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""A project (guest_view_all_features defaults to False); owner is a member."""
|
||||
project = Project.objects.create(
|
||||
name="Scoped Project",
|
||||
identifier="SP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guest(db, workspace, project):
|
||||
"""An active project GUEST (role=5)."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"guest-{unique_id}@plane.so",
|
||||
username=f"guest_{unique_id}",
|
||||
first_name="Guest",
|
||||
last_name="User",
|
||||
)
|
||||
user.set_password("test-password")
|
||||
user.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=user, role=5)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=user, workspace=workspace, role=5
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guest_client(guest):
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=guest)
|
||||
return client
|
||||
|
||||
|
||||
def _make_issue(name, project, workspace, author):
|
||||
"""Create an issue with a deterministic ``created_by``.
|
||||
|
||||
``BaseModel.save`` auto-sets ``created_by`` from the current request user
|
||||
(None/anonymous under tests), so a ``created_by=`` kwarg to ``create`` is
|
||||
overwritten. Passing ``created_by_id`` to ``save`` sets it explicitly.
|
||||
"""
|
||||
issue = Issue(name=name, project=project, workspace=workspace)
|
||||
issue.save(created_by_id=author.id)
|
||||
return issue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def own_issue(db, workspace, project, guest):
|
||||
"""An issue authored by the guest."""
|
||||
return _make_issue("Guest's own issue", project, workspace, guest)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def foreign_issue(db, workspace, project, create_user):
|
||||
"""An issue authored by someone other than the guest."""
|
||||
return _make_issue("Someone else's issue", project, workspace, create_user)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestIssueListGuestScope:
|
||||
"""A restricted guest must only get back issues they authored."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_guest_cannot_read_foreign_issue(
|
||||
self, guest_client, workspace, project, own_issue, foreign_issue
|
||||
):
|
||||
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
|
||||
response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
returned_ids = {str(row["id"]) for row in response.data}
|
||||
assert str(own_issue.id) in returned_ids
|
||||
assert str(foreign_issue.id) not in returned_ids, (
|
||||
f"Guest read a foreign issue: {response.data!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_member_reads_all_requested_issues(
|
||||
self, session_client, workspace, project, own_issue, foreign_issue
|
||||
):
|
||||
"""Positive control: a full member (owner) still gets every requested issue."""
|
||||
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
|
||||
response = session_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned_ids = {str(row["id"]) for row in response.data}
|
||||
assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_guest_with_view_all_reads_all_requested_issues(
|
||||
self, guest_client, workspace, project, own_issue, foreign_issue
|
||||
):
|
||||
"""When guest_view_all_features is enabled, the guest sees all requested issues."""
|
||||
project.guest_view_all_features = True
|
||||
project.save(update_fields=["guest_view_all_features"])
|
||||
|
||||
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
|
||||
response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned_ids = {str(row["id"]) for row in response.data}
|
||||
assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Regression tests for page order_by ORM injection (WEB-8110).
|
||||
|
||||
PageViewSet.get_queryset passed the raw order_by query param into .order_by(),
|
||||
which resolves field names at call time — an unknown field raised FieldError
|
||||
(500 DoS) and a relation path (e.g. owned_by__password) enabled ORM relational
|
||||
traversal. The param is now sanitized against PAGE_ORDER_BY_ALLOWLIST.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Page, Project, ProjectMember, ProjectPage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_with_page(db, workspace, create_user):
|
||||
project = Project.objects.create(name="P", identifier="PRD", workspace=workspace)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace, project=project, member=create_user, role=20, is_active=True
|
||||
)
|
||||
page = Page.objects.create(workspace=workspace, owned_by=create_user, access=Page.PUBLIC_ACCESS, name="pg")
|
||||
ProjectPage.objects.create(workspace=workspace, project=project, page=page)
|
||||
return project, page
|
||||
|
||||
|
||||
def _pages_url(slug, project_id):
|
||||
return f"/api/workspaces/{slug}/projects/{project_id}/pages/"
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestPageOrderByAllowlist:
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"order_by",
|
||||
[
|
||||
"password", # invalid field → FieldError (500) pre-fix
|
||||
"bogus__field__x", # invalid relation path → FieldError (500) pre-fix
|
||||
"owned_by__password", # valid relation path → ORM traversal pre-fix
|
||||
],
|
||||
)
|
||||
def test_malicious_order_by_is_rejected(self, session_client, workspace, project_with_page, order_by):
|
||||
project, _ = project_with_page
|
||||
|
||||
response = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": order_by})
|
||||
|
||||
# Sanitized to the safe default — no 500, no traversal.
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("order_by", ["name", "-name", "created_at", "-created_at", "updated_at", "sort_order"])
|
||||
def test_allowlisted_order_by_is_accepted(self, session_client, workspace, project_with_page, order_by):
|
||||
project, _ = project_with_page
|
||||
|
||||
response = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": order_by})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_no_order_by_param_defaults_ok(self, session_client, workspace, project_with_page):
|
||||
project, _ = project_with_page
|
||||
|
||||
response = session_client.get(_pages_url(workspace.slug, project.id))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_allowlisted_order_by_actually_orders_results(self, session_client, workspace, create_user):
|
||||
"""An allowlisted order_by must actually affect the result ordering —
|
||||
guards against the param being silently overridden by a later
|
||||
.order_by() call."""
|
||||
project = Project.objects.create(name="P2", identifier="ORD", workspace=workspace)
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace, project=project, member=create_user, role=20, is_active=True
|
||||
)
|
||||
# Non-favorite public pages so the favourite-first primary sort is a
|
||||
# no-op and the secondary (name) ordering is observable.
|
||||
for name in ("Gamma", "Alpha", "Beta"):
|
||||
page = Page.objects.create(
|
||||
workspace=workspace, owned_by=create_user, access=Page.PUBLIC_ACCESS, name=name
|
||||
)
|
||||
ProjectPage.objects.create(workspace=workspace, project=project, page=page)
|
||||
|
||||
asc = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": "name"})
|
||||
desc = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": "-name"})
|
||||
|
||||
assert asc.status_code == status.HTTP_200_OK
|
||||
assert [p["name"] for p in asc.json()] == ["Alpha", "Beta", "Gamma"]
|
||||
assert [p["name"] for p in desc.json()] == ["Gamma", "Beta", "Alpha"]
|
||||
@@ -0,0 +1,149 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Regression tests for GHSA-g49r-p85q-qq2w / GHSA-ghcr-frqr-6pqr.
|
||||
|
||||
ProjectPagePermission verified that the caller was a member of the URL
|
||||
project_id, but PageVersionEndpoint resolved the page (and its versions) by
|
||||
workspace + page_id only. A member of one project could therefore read the
|
||||
page versions of a public page belonging to a *different* project in the same
|
||||
workspace via that project's URL.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
PageVersion,
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectPage,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def _page_versions_url(slug, project_id, page_id, pk=None):
|
||||
base = f"/api/workspaces/{slug}/projects/{project_id}/pages/{page_id}/versions/"
|
||||
return f"{base}{pk}/" if pk else base
|
||||
|
||||
|
||||
def _make_project(workspace, identifier):
|
||||
return Project.objects.create(
|
||||
name=f"Project {identifier}",
|
||||
identifier=identifier,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
|
||||
def _make_page(workspace, project, owner, access=Page.PUBLIC_ACCESS):
|
||||
page = Page.objects.create(
|
||||
workspace=workspace,
|
||||
owned_by=owner,
|
||||
access=access,
|
||||
name="Secret page",
|
||||
)
|
||||
ProjectPage.objects.create(workspace=workspace, project=project, page=page)
|
||||
return page
|
||||
|
||||
|
||||
def _make_version(workspace, page, owner):
|
||||
return PageVersion.objects.create(
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
owned_by=owner,
|
||||
description_html="<p>secret</p>",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestPageVersionProjectScope:
|
||||
"""The attacker (create_user / session_client) is an active member of
|
||||
project_a only. Victim owns a public page in project_b."""
|
||||
|
||||
def _setup(self, workspace, attacker):
|
||||
victim = User.objects.create(email="victim@plane.so", username=f"victim_{uuid.uuid4().hex[:8]}")
|
||||
|
||||
project_a = _make_project(workspace, "PRJA")
|
||||
project_b = _make_project(workspace, "PRJB")
|
||||
|
||||
# Attacker is an active member of project A only.
|
||||
ProjectMember.objects.create(workspace=workspace, project=project_a, member=attacker, role=20)
|
||||
|
||||
# Public page + version living in project B (attacker is NOT a member).
|
||||
page_b = _make_page(workspace, project_b, victim)
|
||||
version_b = _make_version(workspace, page_b, victim)
|
||||
|
||||
return victim, project_a, project_b, page_b, version_b
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cross_project_version_list_denied(self, session_client, workspace, create_user):
|
||||
"""Listing another project's page versions via a project the attacker
|
||||
belongs to must be denied (was a 200 leak)."""
|
||||
_, project_a, _, page_b, _ = self._setup(workspace, create_user)
|
||||
|
||||
response = session_client.get(_page_versions_url(workspace.slug, project_a.id, page_b.id))
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cross_project_version_detail_denied(self, session_client, workspace, create_user):
|
||||
"""Reading a single cross-project page version must be denied."""
|
||||
_, project_a, _, page_b, version_b = self._setup(workspace, create_user)
|
||||
|
||||
response = session_client.get(
|
||||
_page_versions_url(workspace.slug, project_a.id, page_b.id, pk=version_b.id)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_same_project_public_page_versions_allowed(self, session_client, workspace, create_user):
|
||||
"""A public page that genuinely belongs to the attacker's project is
|
||||
still readable, and its versions are returned."""
|
||||
victim, project_a, _, _, _ = self._setup(workspace, create_user)
|
||||
|
||||
# Public page owned by the victim but linked to project A (attacker is a
|
||||
# member of A). Exercises the public-page access branch (not owner).
|
||||
page_a = _make_page(workspace, project_a, victim)
|
||||
version_a = _make_version(workspace, page_a, victim)
|
||||
|
||||
response = session_client.get(_page_versions_url(workspace.slug, project_a.id, page_a.id))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned_ids = {str(item["id"]) for item in response.json()}
|
||||
assert str(version_a.id) in returned_ids
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_revoked_project_link_denied(self, session_client, workspace, create_user):
|
||||
"""A page whose ProjectPage link to the attacker's project was
|
||||
soft-deleted (page removed from the project) must be denied, even
|
||||
though the attacker is a member of that project."""
|
||||
victim, project_a, _, _, _ = self._setup(workspace, create_user)
|
||||
|
||||
page = Page.objects.create(
|
||||
workspace=workspace, owned_by=victim, access=Page.PUBLIC_ACCESS, name="Removed page"
|
||||
)
|
||||
# Link exists but is soft-deleted → the page no longer belongs to A.
|
||||
ProjectPage.objects.create(
|
||||
workspace=workspace, project=project_a, page=page, deleted_at=timezone.now()
|
||||
)
|
||||
_make_version(workspace, page, victim)
|
||||
|
||||
response = session_client.get(_page_versions_url(workspace.slug, project_a.id, page.id))
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cross_project_version_list_not_a_member_anywhere(self, session_client, workspace, create_user):
|
||||
"""Sanity: a project the attacker is not a member of is denied outright."""
|
||||
_, _, project_b, page_b, _ = self._setup(workspace, create_user)
|
||||
|
||||
response = session_client.get(_page_versions_url(workspace.slug, project_b.id, page_b.id))
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
524
apps/api/plane/tests/contract/app/test_project_app.py
Normal file
524
apps/api/plane/tests/contract/app/test_project_app.py
Normal file
@@ -0,0 +1,524 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
import uuid
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectUserProperty,
|
||||
State,
|
||||
WorkspaceMember,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class TestProjectBase:
|
||||
def get_project_url(self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False) -> str:
|
||||
"""
|
||||
Constructs the project endpoint URL for the given workspace as reverse() is
|
||||
unreliable due to duplicate 'name' values in URL patterns ('api' and 'app').
|
||||
|
||||
Args:
|
||||
workspace_slug (str): The slug of the workspace.
|
||||
pk (uuid.UUID, optional): The primary key of a specific project.
|
||||
details (bool, optional): If True, constructs the URL for the
|
||||
project details endpoint. Defaults to False.
|
||||
"""
|
||||
# Establish the common base URL for all project-related endpoints.
|
||||
base_url = f"/api/workspaces/{workspace_slug}/projects/"
|
||||
|
||||
# Specific project instance URL.
|
||||
if pk:
|
||||
return f"{base_url}{pk}/"
|
||||
|
||||
# Append 'details/' to the base URL.
|
||||
if details:
|
||||
return f"{base_url}details/"
|
||||
|
||||
# Return the base project list URL.
|
||||
return base_url
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectAPIPost(TestProjectBase):
|
||||
"""Test project POST operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_empty_data(self, session_client, workspace):
|
||||
"""Test creating a project with empty data"""
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
|
||||
# Test with empty data
|
||||
response = session_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_valid_data(self, session_client, workspace, create_user):
|
||||
url = self.get_project_url(workspace.slug)
|
||||
|
||||
project_data = {
|
||||
"name": "New Project Test",
|
||||
"identifier": "NPT",
|
||||
}
|
||||
|
||||
user = create_user
|
||||
|
||||
# Make the request
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
# Check response status
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify project was created
|
||||
assert Project.objects.count() == 1
|
||||
project = Project.objects.get(name=project_data["name"])
|
||||
assert project.workspace == workspace
|
||||
|
||||
# Check if the member is created with the correct role
|
||||
assert ProjectMember.objects.count() == 1
|
||||
project_member = ProjectMember.objects.filter(project=project, member=user).first()
|
||||
assert project_member.role == 20 # Administrator
|
||||
assert project_member.is_active is True
|
||||
|
||||
# Verify ProjectUserProperty was created
|
||||
assert ProjectUserProperty.objects.filter(project=project, user=user).exists()
|
||||
|
||||
# Verify default states were created
|
||||
states = State.objects.filter(project=project)
|
||||
assert states.count() == 5
|
||||
expected_states = ["Backlog", "Todo", "In Progress", "Done", "Cancelled"]
|
||||
state_names = list(states.values_list("name", flat=True))
|
||||
assert set(state_names) == set(expected_states)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_project_lead(self, session_client, workspace, create_user):
|
||||
"""Test creating project with a different project lead"""
|
||||
# Create another user to be project lead
|
||||
project_lead = User.objects.create_user(email="lead@example.com", username="projectlead")
|
||||
|
||||
# Add project lead to workspace
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=project_lead, role=15)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Project with Lead",
|
||||
"identifier": "PWL",
|
||||
"project_lead": project_lead.id,
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify both creator and project lead are administrators
|
||||
project = Project.objects.get(name=project_data["name"])
|
||||
assert ProjectMember.objects.filter(project=project, role=20).count() == 2
|
||||
|
||||
# Verify both have ProjectUserProperty
|
||||
assert ProjectUserProperty.objects.filter(project=project).count() == 2
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_guest_forbidden(self, session_client, workspace):
|
||||
"""Test that guests cannot create projects"""
|
||||
guest_user = User.objects.create_user(email="guest@example.com", username="guest")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5)
|
||||
|
||||
session_client.force_authenticate(user=guest_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Guest Project",
|
||||
"identifier": "GP",
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Project.objects.count() == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_unauthenticated(self, client, workspace):
|
||||
"""Test unauthenticated access"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Unauth Project",
|
||||
"identifier": "UP",
|
||||
}
|
||||
|
||||
response = client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_duplicate_name(self, session_client, workspace, create_user):
|
||||
"""Test creating project with duplicate name"""
|
||||
# Create first project
|
||||
Project.objects.create(name="Duplicate Name", identifier="DN1", workspace=workspace)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Duplicate Name",
|
||||
"identifier": "DN2",
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_duplicate_identifier(self, session_client, workspace, create_user):
|
||||
"""Test creating project with duplicate identifier"""
|
||||
Project.objects.create(name="First Project", identifier="DUP", workspace=workspace)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Second Project",
|
||||
"identifier": "DUP",
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_missing_required_fields(self, session_client, workspace, create_user):
|
||||
"""Test validation with missing required fields"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
|
||||
# Test missing name
|
||||
response = session_client.post(url, {"identifier": "MN"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test missing identifier
|
||||
response = session_client.post(url, {"name": "Missing Identifier"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_all_optional_fields(self, session_client, workspace, create_user):
|
||||
"""Test creating project with all optional fields"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Full Project",
|
||||
"identifier": "FP",
|
||||
"description": "A comprehensive test project",
|
||||
"network": 2,
|
||||
"cycle_view": True,
|
||||
"issue_views_view": False,
|
||||
"module_view": True,
|
||||
"page_view": False,
|
||||
"inbox_view": True,
|
||||
"guest_view_all_features": True,
|
||||
"logo_props": {
|
||||
"in_use": "emoji",
|
||||
"emoji": {"value": "🚀", "unicode": "1f680"},
|
||||
},
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
response_data = response.json()
|
||||
assert response_data["description"] == project_data["description"]
|
||||
assert response_data["network"] == project_data["network"]
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectAPIGet(TestProjectBase):
|
||||
"""Test project GET operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_projects_authenticated_admin(self, session_client, workspace, create_user):
|
||||
"""Test listing projects as workspace admin"""
|
||||
# Create a project
|
||||
project = Project.objects.create(name="Test Project", identifier="TP", workspace=workspace)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Test Project"
|
||||
assert data[0]["identifier"] == "TP"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_projects_authenticated_guest(self, session_client, workspace):
|
||||
"""Test listing projects as workspace guest"""
|
||||
# Create a guest user
|
||||
guest_user = User.objects.create_user(email="guest@example.com", username="guest")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5, is_active=True)
|
||||
|
||||
# Create projects
|
||||
project1 = Project.objects.create(name="Project 1", identifier="P1", workspace=workspace)
|
||||
|
||||
Project.objects.create(name="Project 2", identifier="P2", workspace=workspace)
|
||||
|
||||
# Add guest to only one project
|
||||
ProjectMember.objects.create(project=project1, member=guest_user, role=10, is_active=True)
|
||||
|
||||
session_client.force_authenticate(user=guest_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
# Guest should only see projects they're members of
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Project 1"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_projects_unauthenticated(self, client, workspace):
|
||||
"""Test listing projects without authentication"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_detail_projects(self, session_client, workspace, create_user):
|
||||
"""Test listing projects with detailed information"""
|
||||
# Create a project
|
||||
project = Project.objects.create(
|
||||
name="Detailed Project",
|
||||
identifier="DP",
|
||||
workspace=workspace,
|
||||
description="A detailed test project",
|
||||
)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, details=True)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Detailed Project"
|
||||
assert data[0]["description"] == "A detailed test project"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_project_success(self, session_client, workspace, create_user):
|
||||
"""Test retrieving a specific project"""
|
||||
# Create a project
|
||||
project = Project.objects.create(
|
||||
name="Retrieve Test Project",
|
||||
identifier="RTP",
|
||||
workspace=workspace,
|
||||
description="Test project for retrieval",
|
||||
)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["name"] == "Retrieve Test Project"
|
||||
assert data["identifier"] == "RTP"
|
||||
assert data["description"] == "Test project for retrieval"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_project_not_found(self, session_client, workspace, create_user):
|
||||
"""Test retrieving a non-existent project"""
|
||||
fake_uuid = uuid.uuid4()
|
||||
url = self.get_project_url(workspace.slug, pk=fake_uuid)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_archived_project(self, session_client, workspace, create_user):
|
||||
"""Test retrieving an archived project"""
|
||||
# Create an archived project
|
||||
project = Project.objects.create(
|
||||
name="Archived Project",
|
||||
identifier="AP",
|
||||
workspace=workspace,
|
||||
archived_at=timezone.now(),
|
||||
)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectAPIPatchDelete(TestProjectBase):
|
||||
"""Test project PATCH, and DELETE operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_project_success(self, session_client, workspace, create_user):
|
||||
"""Test successful partial update of project"""
|
||||
# Create a project
|
||||
project = Project.objects.create(
|
||||
name="Original Project",
|
||||
identifier="OP",
|
||||
workspace=workspace,
|
||||
description="Original description",
|
||||
)
|
||||
|
||||
# Add user as project administrator
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
update_data = {
|
||||
"name": "Updated Project",
|
||||
"description": "Updated description",
|
||||
"cycle_view": True,
|
||||
"module_view": False,
|
||||
}
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify project was updated
|
||||
project.refresh_from_db()
|
||||
assert project.name == "Updated Project"
|
||||
assert project.description == "Updated description"
|
||||
assert project.cycle_view is True
|
||||
assert project.module_view is False
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_project_forbidden_non_admin(self, session_client, workspace):
|
||||
"""Test that non-admin project members cannot update project"""
|
||||
# Create a project
|
||||
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
|
||||
|
||||
# Create a member user (not admin)
|
||||
member_user = User.objects.create_user(email="member@example.com", username="member")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True)
|
||||
ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True)
|
||||
|
||||
session_client.force_authenticate(user=member_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
update_data = {"name": "Hacked Project"}
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_duplicate_name_conflict(self, session_client, workspace, create_user):
|
||||
"""Test updating project with duplicate name returns conflict"""
|
||||
# Create two projects
|
||||
Project.objects.create(name="Project One", identifier="P1", workspace=workspace)
|
||||
project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project2.id)
|
||||
update_data = {"name": "Project One"} # Duplicate name
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_duplicate_identifier_conflict(self, session_client, workspace, create_user):
|
||||
"""Test updating project with duplicate identifier returns conflict"""
|
||||
# Create two projects
|
||||
Project.objects.create(name="Project One", identifier="P1", workspace=workspace)
|
||||
project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project2.id)
|
||||
update_data = {"identifier": "P1"} # Duplicate identifier
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_invalid_data(self, session_client, workspace, create_user):
|
||||
"""Test partial update with invalid data"""
|
||||
project = Project.objects.create(name="Valid Project", identifier="VP", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
update_data = {"name": ""}
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_success_project_admin(self, session_client, workspace, create_user):
|
||||
"""Test successful project deletion by project admin"""
|
||||
project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Project.objects.filter(id=project.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_success_workspace_admin(self, session_client, workspace):
|
||||
"""Test successful project deletion by workspace admin"""
|
||||
# Create workspace admin user
|
||||
workspace_admin = User.objects.create_user(email="admin@example.com", username="admin")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=workspace_admin, role=20, is_active=True)
|
||||
|
||||
project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace)
|
||||
|
||||
session_client.force_authenticate(user=workspace_admin)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Project.objects.filter(id=project.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_forbidden_non_admin(self, session_client, workspace):
|
||||
"""Test that non-admin users cannot delete projects"""
|
||||
# Create a member user (not admin)
|
||||
member_user = User.objects.create_user(email="member@example.com", username="member")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True)
|
||||
|
||||
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True)
|
||||
|
||||
session_client.force_authenticate(user=member_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Project.objects.filter(id=project.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_unauthenticated(self, client, workspace):
|
||||
"""Test unauthenticated project deletion"""
|
||||
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert Project.objects.filter(id=project.id).exists()
|
||||
@@ -0,0 +1,172 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Regression tests for GHSA-hpgm-9r34-c4x5 / GHSA-25gg-cxm8-g7h9.
|
||||
|
||||
A project GUEST (or MEMBER) must not be able to (de)activate other project
|
||||
members by PATCHing ``{"is_active": false}`` while omitting the ``role`` field.
|
||||
Before the fix, every authorization guard in ``ProjectMemberViewSet.partial_update``
|
||||
lived inside ``if "role" in request.data:`` and ``is_active`` was writable through
|
||||
``ProjectMemberSerializer(fields="__all__")`` — so a guest could deactivate any
|
||||
member, including admins, and take over the project.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
def _member_detail_url(slug: str, project_id: uuid.UUID, pk: uuid.UUID) -> str:
|
||||
return f"/api/workspaces/{slug}/projects/{project_id}/members/{pk}/"
|
||||
|
||||
|
||||
def _make_user(email: str) -> User:
|
||||
local_part = email.split("@")[0]
|
||||
user = User.objects.create(email=email, username=local_part, first_name=local_part)
|
||||
user.set_password("test-password")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
def _add_member(workspace, project, user, *, ws_role: int, project_role: int) -> ProjectMember:
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=user, role=ws_role, is_active=True)
|
||||
return ProjectMember.objects.create(
|
||||
workspace=workspace, project=project, member=user, role=project_role, is_active=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""A project owned by ``create_user`` (workspace owner / admin)."""
|
||||
project = Project.objects.create(
|
||||
name="Secure Project",
|
||||
identifier="SEC",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
# create_user is the workspace owner (role=20 via the workspace fixture);
|
||||
# make them a project ADMIN too — this is the takeover victim.
|
||||
ProjectMember.objects.create(
|
||||
workspace=workspace, project=project, member=create_user, role=20, is_active=True
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
class TestProjectMemberIsActiveAuthz:
|
||||
def test_guest_cannot_deactivate_admin(self, workspace, project, create_user):
|
||||
"""A project GUEST must not deactivate a project ADMIN via is_active."""
|
||||
attacker = _make_user("guest-attacker@plane.so")
|
||||
# non-workspace-admin (role 15) so is_workspace_admin bypass does not apply,
|
||||
# project GUEST (role 5)
|
||||
_add_member(workspace, project, attacker, ws_role=15, project_role=5)
|
||||
victim = ProjectMember.objects.get(project=project, member=create_user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=attacker)
|
||||
response = client.patch(
|
||||
_member_detail_url(workspace.slug, project.id, victim.id),
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
victim.refresh_from_db()
|
||||
assert victim.is_active is True
|
||||
|
||||
def test_member_cannot_deactivate_admin(self, workspace, project, create_user):
|
||||
"""A project MEMBER must not deactivate a project ADMIN via is_active."""
|
||||
attacker = _make_user("member-attacker@plane.so")
|
||||
_add_member(workspace, project, attacker, ws_role=15, project_role=15)
|
||||
victim = ProjectMember.objects.get(project=project, member=create_user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=attacker)
|
||||
response = client.patch(
|
||||
_member_detail_url(workspace.slug, project.id, victim.id),
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
victim.refresh_from_db()
|
||||
assert victim.is_active is True
|
||||
|
||||
def test_guest_cannot_deactivate_peer_guest(self, workspace, project):
|
||||
"""A GUEST cannot deactivate another GUEST either (role check applies to all)."""
|
||||
attacker = _make_user("guest-a@plane.so")
|
||||
peer = _make_user("guest-b@plane.so")
|
||||
_add_member(workspace, project, attacker, ws_role=15, project_role=5)
|
||||
peer_member = _add_member(workspace, project, peer, ws_role=15, project_role=5)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=attacker)
|
||||
response = client.patch(
|
||||
_member_detail_url(workspace.slug, project.id, peer_member.id),
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
peer_member.refresh_from_db()
|
||||
assert peer_member.is_active is True
|
||||
|
||||
def test_project_admin_can_deactivate_member(self, workspace, project):
|
||||
"""Positive control: a project ADMIN (non-workspace-admin) may deactivate a MEMBER."""
|
||||
admin = _make_user("project-admin@plane.so")
|
||||
target = _make_user("plain-member@plane.so")
|
||||
# admin is a workspace MEMBER (15) but project ADMIN (20) — exercises the
|
||||
# role-comparison guard rather than the workspace-admin bypass.
|
||||
_add_member(workspace, project, admin, ws_role=15, project_role=20)
|
||||
target_member = _add_member(workspace, project, target, ws_role=15, project_role=15)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin)
|
||||
response = client.patch(
|
||||
_member_detail_url(workspace.slug, project.id, target_member.id),
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
target_member.refresh_from_db()
|
||||
assert target_member.is_active is False
|
||||
|
||||
def test_workspace_admin_with_low_project_role_can_deactivate(self, workspace, project, create_user):
|
||||
"""
|
||||
Positive control: the intended workspace-admin bypass is preserved.
|
||||
|
||||
A workspace ADMIN (role 20) may deactivate any project member — even a
|
||||
project ADMIN — despite holding only a project GUEST role, because
|
||||
is_workspace_admin short-circuits the role-comparison guard. Locks in the
|
||||
bypass so future changes don't silently remove it.
|
||||
"""
|
||||
ws_admin = _make_user("ws-admin@plane.so")
|
||||
# workspace ADMIN (20) but only a project GUEST (5)
|
||||
_add_member(workspace, project, ws_admin, ws_role=20, project_role=5)
|
||||
# victim is the project ADMIN (create_user) set up by the `project` fixture
|
||||
victim = ProjectMember.objects.get(project=project, member=create_user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=ws_admin)
|
||||
response = client.patch(
|
||||
_member_detail_url(workspace.slug, project.id, victim.id),
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
victim.refresh_from_db()
|
||||
assert victim.is_active is False
|
||||
@@ -0,0 +1,575 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for public Spaces board object-ID scoping.
|
||||
|
||||
Regression coverage for WEB-8283.
|
||||
|
||||
The public Spaces board write endpoints (``/api/public/anchor/<anchor>/...``)
|
||||
resolve the ``DeployBoard`` from the URL ``anchor`` but previously trusted the
|
||||
caller-supplied ``issue_id`` / ``comment_id`` / ``intake_id`` verbatim, without
|
||||
verifying the target object belonged to that board's project/workspace. Any
|
||||
authenticated user could therefore write a comment / reaction / vote onto an
|
||||
arbitrary issue in any project (cross-tenant), and read EXTERNAL comments from a
|
||||
different project in the same workspace.
|
||||
|
||||
The fix binds every caller-supplied object id to the board's project + workspace
|
||||
before writing, and scopes the comment-list read to the board's project.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
DeployBoard,
|
||||
Intake,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueReaction,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# URL helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def comments_url(anchor, issue_id):
|
||||
return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/"
|
||||
|
||||
|
||||
def issue_reactions_url(anchor, issue_id):
|
||||
return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/"
|
||||
|
||||
|
||||
def comment_reactions_url(anchor, comment_id):
|
||||
return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/"
|
||||
|
||||
|
||||
def votes_url(anchor, issue_id):
|
||||
return f"/api/public/anchor/{anchor}/issues/{issue_id}/votes/"
|
||||
|
||||
|
||||
def intake_issues_url(anchor, intake_id):
|
||||
return f"/api/public/anchor/{anchor}/intakes/{intake_id}/intake-issues/"
|
||||
|
||||
|
||||
def comment_detail_url(anchor, issue_id, pk):
|
||||
return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/{pk}/"
|
||||
|
||||
|
||||
def issue_reaction_detail_url(anchor, issue_id, reaction_code):
|
||||
return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/{reaction_code}/"
|
||||
|
||||
|
||||
def comment_reaction_detail_url(anchor, comment_id, reaction_code):
|
||||
return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/{reaction_code}/"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_activity(db):
|
||||
"""Stub the deferred activity task so writes never touch the broker."""
|
||||
with (
|
||||
mock.patch("plane.space.views.issue.issue_activity"),
|
||||
mock.patch("plane.space.views.intake.issue_activity"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def board(db, workspace, create_user):
|
||||
"""A published project board (comments/reactions/votes + intake enabled).
|
||||
|
||||
``create_user`` (session_client) is an active member of this project.
|
||||
"""
|
||||
project = Project.objects.create(
|
||||
name="Board Project", identifier="BRD", workspace=workspace, created_by=create_user
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20, is_active=True
|
||||
)
|
||||
state = State.objects.create(
|
||||
name="Todo", project=project, workspace=workspace, group="backlog", default=True
|
||||
)
|
||||
issue = Issue.objects.create(
|
||||
name="Board Issue", workspace=workspace, project=project, state=state, created_by=create_user
|
||||
)
|
||||
comment = IssueComment.objects.create(
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
comment_html="<p>board external comment</p>",
|
||||
access="EXTERNAL",
|
||||
created_by=create_user,
|
||||
actor=create_user,
|
||||
)
|
||||
intake = Intake.objects.create(name="Board Intake", project=project, workspace=workspace)
|
||||
deploy_board = DeployBoard.objects.create(
|
||||
entity_name="project",
|
||||
entity_identifier=project.id,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
is_comments_enabled=True,
|
||||
is_reactions_enabled=True,
|
||||
is_votes_enabled=True,
|
||||
intake=intake,
|
||||
)
|
||||
return {
|
||||
"project": project,
|
||||
"issue": issue,
|
||||
"comment": comment,
|
||||
"intake": intake,
|
||||
"anchor": deploy_board.anchor,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim(db, workspace, create_user):
|
||||
"""A *different* project in the SAME workspace, not published on ``board``."""
|
||||
project = Project.objects.create(
|
||||
name="Victim Project", identifier="VIC", workspace=workspace, created_by=create_user
|
||||
)
|
||||
state = State.objects.create(
|
||||
name="Todo", project=project, workspace=workspace, group="backlog", default=True
|
||||
)
|
||||
issue = Issue.objects.create(
|
||||
name="Victim Issue", workspace=workspace, project=project, state=state, created_by=create_user
|
||||
)
|
||||
comment = IssueComment.objects.create(
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
comment_html="<p>secret external comment</p>",
|
||||
access="EXTERNAL",
|
||||
created_by=create_user,
|
||||
actor=create_user,
|
||||
)
|
||||
intake = Intake.objects.create(name="Victim Intake", project=project, workspace=workspace)
|
||||
return {"project": project, "issue": issue, "comment": comment, "intake": intake}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_other_ws(db, create_user):
|
||||
"""A project in a DIFFERENT workspace — exercises the workspace_id binding
|
||||
(true cross-tenant, matching the advisory's stated impact)."""
|
||||
uid = uuid4().hex[:8]
|
||||
owner = User.objects.create(email=f"victim-owner-{uid}@plane.so", username=f"victim_owner_{uid}")
|
||||
owner.set_password("test-password")
|
||||
owner.save()
|
||||
other_ws = Workspace.objects.create(name="Other WS", owner=owner, slug=f"other-ws-{uid}")
|
||||
WorkspaceMember.objects.create(workspace=other_ws, member=owner, role=20)
|
||||
project = Project.objects.create(
|
||||
name="Other WS Project", identifier="OWP", workspace=other_ws, created_by=owner
|
||||
)
|
||||
state = State.objects.create(
|
||||
name="Todo", project=project, workspace=other_ws, group="backlog", default=True
|
||||
)
|
||||
issue = Issue.objects.create(
|
||||
name="Other WS Issue", workspace=other_ws, project=project, state=state, created_by=owner
|
||||
)
|
||||
return {"workspace": other_ws, "project": project, "issue": issue}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def board_votes_disabled(db, workspace, create_user):
|
||||
"""A published board with voting DISABLED, for the is_votes_enabled gate."""
|
||||
project = Project.objects.create(
|
||||
name="No-Vote Project", identifier="NVP", workspace=workspace, created_by=create_user
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20, is_active=True
|
||||
)
|
||||
state = State.objects.create(
|
||||
name="Todo", project=project, workspace=workspace, group="backlog", default=True
|
||||
)
|
||||
issue = Issue.objects.create(
|
||||
name="No-Vote Issue", workspace=workspace, project=project, state=state, created_by=create_user
|
||||
)
|
||||
deploy_board = DeployBoard.objects.create(
|
||||
entity_name="project",
|
||||
entity_identifier=project.id,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
is_comments_enabled=True,
|
||||
is_reactions_enabled=True,
|
||||
is_votes_enabled=False,
|
||||
intake=None,
|
||||
)
|
||||
return {"project": project, "issue": issue, "anchor": deploy_board.anchor}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attacker_client(db, workspace):
|
||||
"""An authenticated user who is NOT a member of the victim project."""
|
||||
uid = uuid4().hex[:8]
|
||||
user = User.objects.create(email=f"attacker-{uid}@plane.so", username=f"attacker_{uid}")
|
||||
user.set_password("test-password")
|
||||
user.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=user, role=15)
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def internal_comment(db, workspace, board, create_user):
|
||||
"""An INTERNAL (non-public) comment on the board's own issue, with its own
|
||||
reaction - never shown on the public board, so it must not be readable or
|
||||
mutable through the public reaction endpoints either."""
|
||||
comment = IssueComment.objects.create(
|
||||
issue=board["issue"],
|
||||
project=board["project"],
|
||||
workspace=workspace,
|
||||
comment_html="<p>internal only</p>",
|
||||
access="INTERNAL",
|
||||
created_by=create_user,
|
||||
actor=create_user,
|
||||
)
|
||||
reaction = CommentReaction.objects.create(
|
||||
project=board["project"],
|
||||
workspace=workspace,
|
||||
comment=comment,
|
||||
actor=create_user,
|
||||
reaction="fire",
|
||||
)
|
||||
return {"comment": comment, "reaction": reaction}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def board_issue_reaction(db, workspace, board, create_user):
|
||||
"""A reaction already on the board's own issue, for destroy() coverage."""
|
||||
return IssueReaction.objects.create(
|
||||
project=board["project"],
|
||||
workspace=workspace,
|
||||
issue=board["issue"],
|
||||
actor=create_user,
|
||||
reaction="smile",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_issue_reaction(db, victim, create_user):
|
||||
"""A reaction the same user owns, but on an issue in a DIFFERENT project
|
||||
(not published by ``board``) - exercises the project binding on destroy()."""
|
||||
return IssueReaction.objects.create(
|
||||
project=victim["project"],
|
||||
workspace=victim["project"].workspace,
|
||||
issue=victim["issue"],
|
||||
actor=create_user,
|
||||
reaction="smile",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cross-tenant WRITE — must be rejected (404 for issue/comment binding,
|
||||
# 400 for the intake binding mismatch, per each endpoint's contract below)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.contract
|
||||
class TestSpacesBoardObjectScope:
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_comment_on_issue_outside_board_project(self, attacker_client, board, victim):
|
||||
response = attacker_client.post(
|
||||
comments_url(board["anchor"], victim["issue"].id),
|
||||
{"comment_html": "<p>injected</p>"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not IssueComment.objects.filter(
|
||||
issue_id=victim["issue"].id, comment_html="<p>injected</p>"
|
||||
).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_react_to_issue_outside_board_project(self, attacker_client, board, victim):
|
||||
response = attacker_client.post(
|
||||
issue_reactions_url(board["anchor"], victim["issue"].id),
|
||||
{"reaction": "smile"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_vote_on_issue_outside_board_project(self, attacker_client, board, victim):
|
||||
response = attacker_client.post(
|
||||
votes_url(board["anchor"], victim["issue"].id),
|
||||
{"vote": 1},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_react_to_comment_outside_board_project(self, attacker_client, board, victim):
|
||||
response = attacker_client.post(
|
||||
comment_reactions_url(board["anchor"], victim["comment"].id),
|
||||
{"reaction": "smile"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_create_intake_issue_with_foreign_intake(self, attacker_client, board, victim):
|
||||
response = attacker_client.post(
|
||||
intake_issues_url(board["anchor"], victim["intake"].id),
|
||||
{"issue": {"name": "injected intake"}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_comment_on_issue_in_other_workspace(self, attacker_client, board, victim_other_ws):
|
||||
"""The guard binds on workspace_id too — a board's anchor cannot reach an
|
||||
issue in a different workspace (true cross-tenant vector)."""
|
||||
response = attacker_client.post(
|
||||
comments_url(board["anchor"], victim_other_ws["issue"].id),
|
||||
{"comment_html": "<p>injected cross-ws</p>"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not IssueComment.objects.filter(
|
||||
issue_id=victim_other_ws["issue"].id, comment_html="<p>injected cross-ws</p>"
|
||||
).exists()
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Feature gate — vote create must honor is_votes_enabled (parity fix)
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_vote_when_votes_disabled(self, session_client, board_votes_disabled):
|
||||
response = session_client.post(
|
||||
votes_url(board_votes_disabled["anchor"], board_votes_disabled["issue"].id),
|
||||
{"vote": 1},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Cross-project READ leak — comment list must not surface other projects
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_comment_list_does_not_leak_cross_project(self, attacker_client, board, victim):
|
||||
response = attacker_client.get(comments_url(board["anchor"], victim["issue"].id))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
results = response.data["results"] if isinstance(response.data, dict) else response.data
|
||||
returned_ids = {str(c["id"]) for c in results}
|
||||
assert str(victim["comment"].id) not in returned_ids, (
|
||||
"Victim project's EXTERNAL comment leaked through another board's anchor"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_list_returns_own_project_comments(self, session_client, board):
|
||||
"""Positive control: the board's own EXTERNAL comments are still listed
|
||||
(guards against an over-broad project_id filter returning nothing)."""
|
||||
response = session_client.get(comments_url(board["anchor"], board["issue"].id))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
results = response.data["results"] if isinstance(response.data, dict) else response.data
|
||||
returned_ids = {str(c["id"]) for c in results}
|
||||
assert str(board["comment"].id) in returned_ids, (
|
||||
"Board's own EXTERNAL comment was wrongly filtered out"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Positive controls — legitimate writes on the board's own issue still work
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_can_comment_on_issue_in_board_project(self, session_client, board):
|
||||
response = session_client.post(
|
||||
comments_url(board["anchor"], board["issue"].id),
|
||||
{"comment_html": "<p>legit</p>"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_vote_on_issue_in_board_project(self, session_client, board):
|
||||
response = session_client.post(
|
||||
votes_url(board["anchor"], board["issue"].id),
|
||||
{"vote": 1},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_react_to_issue_in_board_project(self, session_client, board):
|
||||
response = session_client.post(
|
||||
issue_reactions_url(board["anchor"], board["issue"].id),
|
||||
{"reaction": "smile"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_react_to_comment_in_board_project(self, session_client, board):
|
||||
response = session_client.post(
|
||||
comment_reactions_url(board["anchor"], board["comment"].id),
|
||||
{"reaction": "smile"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_create_intake_issue_with_own_intake(self, session_client, board):
|
||||
response = session_client.post(
|
||||
intake_issues_url(board["anchor"], board["intake"].id),
|
||||
{"issue": {"name": "legit intake issue"}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Read/update/delete scope gaps — the board-scope guards were only applied to
|
||||
# create(). A caller could still read reactions on an INTERNAL (non-public)
|
||||
# comment, or reach a comment/reaction they authored through a board it does
|
||||
# not belong to, via list/update/delete. These mirror the create() coverage
|
||||
# above for the list/partial_update/destroy paths.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.contract
|
||||
class TestSpacesBoardObjectScopeMutations:
|
||||
# ----------------------------------------------------------------------- #
|
||||
# CommentReactionPublicViewSet — INTERNAL comments must stay invisible
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_comment_reaction_list_does_not_leak_internal_comment(self, session_client, board, internal_comment):
|
||||
response = session_client.get(comment_reactions_url(board["anchor"], internal_comment["comment"].id))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
results = response.data["results"] if isinstance(response.data, dict) else response.data
|
||||
returned_ids = {str(r["id"]) for r in results}
|
||||
assert str(internal_comment["reaction"].id) not in returned_ids, (
|
||||
"Reaction on an INTERNAL comment leaked through the public reaction list"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_reaction_on_internal_comment(self, session_client, board, internal_comment):
|
||||
response = session_client.delete(
|
||||
comment_reaction_detail_url(board["anchor"], internal_comment["comment"].id, "fire")
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert CommentReaction.objects.filter(id=internal_comment["reaction"].id).exists(), (
|
||||
"Reaction on an INTERNAL comment was deleted through the public board"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_delete_own_reaction_on_external_comment(self, session_client, board, create_user):
|
||||
reaction = CommentReaction.objects.create(
|
||||
project=board["project"],
|
||||
workspace=board["project"].workspace,
|
||||
comment=board["comment"],
|
||||
actor=create_user,
|
||||
reaction="tada",
|
||||
)
|
||||
response = session_client.delete(comment_reaction_detail_url(board["anchor"], board["comment"].id, "tada"))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not CommentReaction.objects.filter(id=reaction.id).exists()
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# IssueCommentPublicViewSet.partial_update / destroy — must bind pk to
|
||||
# this board's project + workspace + issue_id, not just the actor
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_update_comment_reached_through_wrong_board(self, session_client, board, victim):
|
||||
response = session_client.patch(
|
||||
comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id),
|
||||
{"comment_html": "<p>hijacked</p>"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
victim["comment"].refresh_from_db()
|
||||
assert victim["comment"].comment_html != "<p>hijacked</p>"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_comment_reached_through_wrong_board(self, session_client, board, victim):
|
||||
response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert IssueComment.objects.filter(id=victim["comment"].id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_update_own_comment_in_board_project(self, session_client, board):
|
||||
response = session_client.patch(
|
||||
comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id),
|
||||
{"comment_html": "<p>edited</p>"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_delete_own_comment_in_board_project(self, session_client, board):
|
||||
response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not IssueComment.objects.filter(id=board["comment"].id).exists()
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# IssueReactionPublicViewSet.destroy — must bind to project, not just
|
||||
# workspace
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_reaction_on_issue_outside_board_project(
|
||||
self, session_client, board, victim, victim_issue_reaction
|
||||
):
|
||||
response = session_client.delete(issue_reaction_detail_url(board["anchor"], victim["issue"].id, "smile"))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert IssueReaction.objects.filter(id=victim_issue_reaction.id).exists(), (
|
||||
"Reaction on an issue outside the board's project was deleted through this board"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_delete_own_reaction_on_issue_in_board_project(self, session_client, board, board_issue_reaction):
|
||||
response = session_client.delete(issue_reaction_detail_url(board["anchor"], board["issue"].id, "smile"))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not IssueReaction.objects.filter(id=board_issue_reaction.id).exists()
|
||||
@@ -0,0 +1,224 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for ``SubIssuesEndpoint`` cross-project scoping.
|
||||
|
||||
``SubIssuesEndpoint``
|
||||
(``/workspaces/<slug>/projects/<project_id>/issues/<issue_id>/sub-issues/``) is
|
||||
guarded only by ``ProjectEntityPermission``, which checks that the caller is a
|
||||
member of the URL ``project_id`` — not that ``issue_id`` belongs to it. Both
|
||||
handlers then resolved issues without scoping to the URL project:
|
||||
|
||||
* GET filtered sub-issues by ``parent_id`` + ``workspace__slug`` only, leaking
|
||||
the titles/metadata of another project's sub-issues (read IDOR).
|
||||
* POST loaded the parent by bare ``pk`` (no workspace/project scope) and filtered
|
||||
the moved sub-issues by ``workspace__slug`` only, letting a member re-parent
|
||||
issues from other projects/workspaces (write IDOR).
|
||||
|
||||
The fix scopes every lookup to the URL ``project_id`` (and binds the parent to
|
||||
the workspace), so a caller can only ever touch sub-issues of the project they
|
||||
are actually a member of.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
SUB_ISSUES_URL = (
|
||||
"/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/sub-issues/"
|
||||
)
|
||||
|
||||
|
||||
def _make_issue(name, project, workspace, author, parent=None):
|
||||
"""Create an issue with a deterministic ``created_by``.
|
||||
|
||||
``BaseModel.save`` auto-sets ``created_by`` from the current request user
|
||||
(None/anonymous under tests), so a ``created_by=`` kwarg to ``create`` is
|
||||
overwritten. Passing ``created_by_id`` to ``save`` sets it explicitly.
|
||||
"""
|
||||
issue = Issue(name=name, project=project, workspace=workspace, parent=parent)
|
||||
issue.save(created_by_id=author.id)
|
||||
return issue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_a(db, workspace, create_user):
|
||||
"""The project the caller is a member of (the URL project)."""
|
||||
project = Project.objects.create(
|
||||
name="Project A",
|
||||
identifier="PA",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_b(db, workspace, create_user):
|
||||
"""A sibling project in the same workspace the caller is NOT a member of."""
|
||||
return Project.objects.create(
|
||||
name="Project B",
|
||||
identifier="PB",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
# --- Project B (victim) issues -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parent_b(db, workspace, project_b, create_user):
|
||||
return _make_issue("B parent", project_b, workspace, create_user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sub_b(db, workspace, project_b, parent_b, create_user):
|
||||
return _make_issue("B sub-issue", project_b, workspace, create_user, parent=parent_b)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orphan_b(db, workspace, project_b, create_user):
|
||||
"""A standalone (unparented) issue in project B — target of a write IDOR."""
|
||||
return _make_issue("B orphan", project_b, workspace, create_user)
|
||||
|
||||
|
||||
# --- Project A (caller's) issues ----------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parent_a(db, workspace, project_a, create_user):
|
||||
return _make_issue("A parent", project_a, workspace, create_user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sub_a(db, workspace, project_a, parent_a, create_user):
|
||||
return _make_issue("A sub-issue", project_a, workspace, create_user, parent=parent_a)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orphan_a(db, workspace, project_a, create_user):
|
||||
return _make_issue("A orphan", project_a, workspace, create_user)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestSubIssuesCrossProjectScope:
|
||||
"""A project member must not read or write another project's sub-issue graph."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_read_cross_project_sub_issues_hidden(
|
||||
self, session_client, workspace, project_a, parent_b, sub_b
|
||||
):
|
||||
"""GET with a parent that lives in a project the caller isn't in leaks nothing.
|
||||
|
||||
The URL project is A (caller is a member); the parent issue lives in B.
|
||||
Before the fix the endpoint returned B's sub-issues; now the project scope
|
||||
excludes them.
|
||||
"""
|
||||
url = SUB_ISSUES_URL.format(
|
||||
slug=workspace.slug, project_id=project_a.id, issue_id=parent_b.id
|
||||
)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
returned_ids = {str(row["id"]) for row in response.data["sub_issues"]}
|
||||
assert str(sub_b.id) not in returned_ids, (
|
||||
f"Leaked cross-project sub-issue: {response.data!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_write_cross_project_reparent_blocked(
|
||||
self, session_client, workspace, project_a, parent_b, orphan_b
|
||||
):
|
||||
"""POST cannot re-parent an issue onto a parent outside the URL project.
|
||||
|
||||
Parent B is not in project A, so the scoped lookup 404s and no issue is
|
||||
moved. Before the fix the parent resolved by bare pk and the orphan was
|
||||
re-parented.
|
||||
"""
|
||||
url = SUB_ISSUES_URL.format(
|
||||
slug=workspace.slug, project_id=project_a.id, issue_id=parent_b.id
|
||||
)
|
||||
response = session_client.post(url, {"sub_issue_ids": [str(orphan_b.id)]}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Expected 404, got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
orphan_b.refresh_from_db()
|
||||
assert orphan_b.parent_id is None, "Orphan issue was re-parented across projects"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_write_cross_project_sub_issue_ids_ignored(
|
||||
self, session_client, workspace, project_a, parent_a, orphan_b, mocker
|
||||
):
|
||||
"""Even with an in-project parent, sub_issue_ids from another project are ignored.
|
||||
|
||||
Parent A is valid for the URL project, but ``orphan_b`` lives in project B,
|
||||
so the project-scoped ``sub_issue_ids`` filter must exclude it and leave its
|
||||
parent untouched — and no activity event may be enqueued for it (the activity
|
||||
task does an unscoped lookup + updated_at bump on whatever id it receives).
|
||||
"""
|
||||
mock_activity = mocker.patch(
|
||||
"plane.app.views.issue.sub_issue.issue_activity.delay"
|
||||
)
|
||||
url = SUB_ISSUES_URL.format(
|
||||
slug=workspace.slug, project_id=project_a.id, issue_id=parent_a.id
|
||||
)
|
||||
response = session_client.post(url, {"sub_issue_ids": [str(orphan_b.id)]}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
# The foreign issue must not appear in the response body either.
|
||||
assert response.data["sub_issues"] == [], f"Leaked cross-project issue: {response.data!r}"
|
||||
assert response.data["state_distribution"] == {}, f"Leaked state_distribution: {response.data!r}"
|
||||
orphan_b.refresh_from_db()
|
||||
assert orphan_b.parent_id is None, "Cross-project issue was re-parented"
|
||||
# No activity may be dispatched for the excluded cross-project issue.
|
||||
dispatched_ids = {call.kwargs.get("issue_id") for call in mock_activity.call_args_list}
|
||||
assert str(orphan_b.id) not in dispatched_ids, (
|
||||
f"Activity dispatched for a cross-project issue: {mock_activity.call_args_list!r}"
|
||||
)
|
||||
|
||||
# --- Positive controls: legitimate same-project use still works -----------
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_read_same_project_sub_issues_visible(
|
||||
self, session_client, workspace, project_a, parent_a, sub_a
|
||||
):
|
||||
url = SUB_ISSUES_URL.format(
|
||||
slug=workspace.slug, project_id=project_a.id, issue_id=parent_a.id
|
||||
)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned_ids = {str(row["id"]) for row in response.data["sub_issues"]}
|
||||
assert str(sub_a.id) in returned_ids, (
|
||||
f"Expected sub-issue {sub_a.id} in {response.data!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_write_same_project_reparent_allowed(
|
||||
self, session_client, workspace, project_a, parent_a, orphan_a
|
||||
):
|
||||
url = SUB_ISSUES_URL.format(
|
||||
slug=workspace.slug, project_id=project_a.id, issue_id=parent_a.id
|
||||
)
|
||||
response = session_client.post(url, {"sub_issue_ids": [str(orphan_a.id)]}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
orphan_a.refresh_from_db()
|
||||
assert str(orphan_a.parent_id) == str(parent_a.id), "Same-project re-parent did not persist"
|
||||
@@ -0,0 +1,106 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Regression tests for WEB-8103.
|
||||
|
||||
WebhookEndpoint list/retrieve/patch passed a fields= allowlist that excluded
|
||||
secret_key, but DynamicBaseSerializer.__init__ discards the allowlist, so the
|
||||
HMAC secret_key leaked on every webhook read. WebhookSerializer now hides
|
||||
secret_key by default and only reveals it on create / secret regeneration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.app.serializers import WebhookSerializer
|
||||
from plane.db.models import Webhook
|
||||
|
||||
|
||||
def _webhooks_url(slug, pk=None, regenerate=False):
|
||||
base = f"/api/workspaces/{slug}/webhooks/"
|
||||
if pk and regenerate:
|
||||
return f"{base}{pk}/regenerate/"
|
||||
if pk:
|
||||
return f"{base}{pk}/"
|
||||
return base
|
||||
|
||||
|
||||
def _make_webhook(workspace, url="https://example.com/hook"):
|
||||
return Webhook.objects.create(workspace=workspace, url=url)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWebhookSecretKeyScope:
|
||||
@pytest.mark.django_db
|
||||
def test_list_does_not_leak_secret_key(self, session_client, workspace):
|
||||
_make_webhook(workspace)
|
||||
|
||||
response = session_client.get(_webhooks_url(workspace.slug))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
payload = response.json()
|
||||
assert payload, "expected at least one webhook"
|
||||
assert all("secret_key" not in item for item in payload)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_does_not_leak_secret_key(self, session_client, workspace):
|
||||
webhook = _make_webhook(workspace)
|
||||
|
||||
response = session_client.get(_webhooks_url(workspace.slug, pk=webhook.id))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "secret_key" not in response.json()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_does_not_leak_secret_key(self, session_client, workspace):
|
||||
webhook = _make_webhook(workspace)
|
||||
|
||||
# Patch a non-url field so URL SSRF validation is not exercised.
|
||||
response = session_client.patch(
|
||||
_webhooks_url(workspace.slug, pk=webhook.id), {"is_active": False}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "secret_key" not in response.json()
|
||||
assert response.json()["is_active"] is False
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_regenerate_reveals_secret_key(self, session_client, workspace):
|
||||
webhook = _make_webhook(workspace)
|
||||
old_secret = webhook.secret_key
|
||||
|
||||
response = session_client.post(_webhooks_url(workspace.slug, pk=webhook.id, regenerate=True))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
body = response.json()
|
||||
assert "secret_key" in body
|
||||
assert body["secret_key"] and body["secret_key"] != old_secret
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_reveals_secret_key(self, session_client, workspace, monkeypatch):
|
||||
# Bypass only the network boundary (DNS/IP SSRF resolution); the domain and
|
||||
# schema checks in _validate_webhook_url still run for example.com, and the
|
||||
# test survives a rename of the private method.
|
||||
monkeypatch.setattr("plane.app.serializers.webhook.validate_url", lambda *a, **k: None)
|
||||
|
||||
response = session_client.post(
|
||||
_webhooks_url(workspace.slug), {"url": "https://example.com/hook"}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json().get("secret_key")
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_serializer_hides_secret_by_default_and_reveals_with_flag(self, workspace):
|
||||
webhook = _make_webhook(workspace)
|
||||
|
||||
assert "secret_key" not in WebhookSerializer(webhook).data
|
||||
# An explicit fields= request must not re-open the leak either: enforcement
|
||||
# lives in to_representation, not the (currently no-op) DynamicBaseSerializer
|
||||
# field allowlist. Pins the enforcement point so a future _filter_fields fix
|
||||
# can't silently re-expose the key.
|
||||
assert "secret_key" not in WebhookSerializer(webhook, fields=("secret_key",)).data
|
||||
revealed = WebhookSerializer(webhook, context={"show_secret_key": True}).data
|
||||
assert revealed["secret_key"] == webhook.secret_key
|
||||
77
apps/api/plane/tests/contract/app/test_workspace_app.py
Normal file
77
apps/api/plane/tests/contract/app/test_workspace_app.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from unittest.mock import patch
|
||||
|
||||
from plane.db.models import Workspace, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWorkspaceAPI:
|
||||
"""Test workspace CRUD operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_workspace_empty_data(self, session_client):
|
||||
"""Test creating a workspace with empty data"""
|
||||
url = reverse("workspace")
|
||||
|
||||
# Test with empty data
|
||||
response = session_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
|
||||
def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user):
|
||||
"""Test creating a workspace with valid data"""
|
||||
url = reverse("workspace")
|
||||
user = create_user # Use the create_user fixture directly as it returns a user object
|
||||
|
||||
# Test with valid data - include all required fields
|
||||
workspace_data = {
|
||||
"name": "Plane",
|
||||
"slug": "pla-ne-test",
|
||||
"company_name": "Plane Inc.",
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = session_client.post(url, workspace_data, format="json")
|
||||
|
||||
# Check response status
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify workspace was created
|
||||
assert Workspace.objects.count() == 1
|
||||
|
||||
# Check if the member is created
|
||||
assert WorkspaceMember.objects.count() == 1
|
||||
|
||||
# Check other values
|
||||
workspace = Workspace.objects.get(slug=workspace_data["slug"])
|
||||
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user).first()
|
||||
assert workspace.owner == user
|
||||
assert workspace_member.role == 20
|
||||
|
||||
# Verify the workspace_seed task was called
|
||||
mock_workspace_seed.assert_called_once_with(response.data["id"])
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
|
||||
def test_create_duplicate_workspace(self, mock_workspace_seed, session_client):
|
||||
"""Test creating a duplicate workspace"""
|
||||
url = reverse("workspace")
|
||||
|
||||
# Create first workspace
|
||||
session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json")
|
||||
|
||||
# Try to create a workspace with the same slug
|
||||
response = session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json")
|
||||
|
||||
# The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Optionally check the error message to confirm it's related to the duplicate slug
|
||||
assert "slug" in response.data
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for workspace-wide cycles/modules project-scoping.
|
||||
|
||||
Regression coverage for GHSA-wcc5-qgfr-8g9c (WEB-8068). ``WorkspaceCyclesEndpoint``
|
||||
and ``WorkspaceModulesEndpoint`` are guarded only by ``WorkspaceViewerPermission``
|
||||
(any active workspace member) and previously filtered by ``workspace__slug`` alone.
|
||||
That let any workspace member enumerate cycle/module metadata (names, dates, issue
|
||||
counts) for private projects they were not a member of.
|
||||
|
||||
The fix restricts both querysets to projects the requesting user is an active
|
||||
member of, mirroring ``WorkspaceStatesEndpoint`` / ``WorkspaceLabelsEndpoint``.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
Module,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
CYCLES_URL = "/api/workspaces/{slug}/cycles/"
|
||||
MODULES_URL = "/api/workspaces/{slug}/modules/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""A project in the fixture workspace; ``create_user`` is an active member."""
|
||||
project = Project.objects.create(
|
||||
name="Private Project",
|
||||
identifier="PP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycle(db, workspace, project, create_user):
|
||||
return Cycle.objects.create(
|
||||
name="Private Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module(db, workspace, project):
|
||||
return Module.objects.create(
|
||||
name="Private Module",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_client(db, workspace):
|
||||
"""Session client for a workspace member who is NOT in ``project``."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
outsider = User.objects.create(
|
||||
email=f"outsider-{unique_id}@plane.so",
|
||||
username=f"outsider_{unique_id}",
|
||||
first_name="Outsider",
|
||||
last_name="User",
|
||||
)
|
||||
outsider.set_password("test-password")
|
||||
outsider.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=outsider, role=15)
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=outsider)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWorkspaceCyclesModulesProjectScope:
|
||||
"""A workspace member must not see cycles/modules of projects they aren't in."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cycles_hidden_from_non_project_member(self, outsider_client, workspace, cycle):
|
||||
response = outsider_client.get(CYCLES_URL.format(slug=workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert response.data == [], f"Leaked cycle metadata: {response.data!r}"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_modules_hidden_from_non_project_member(self, outsider_client, workspace, module):
|
||||
response = outsider_client.get(MODULES_URL.format(slug=workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert response.data == [], f"Leaked module metadata: {response.data!r}"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cycles_visible_to_project_member(self, session_client, workspace, cycle):
|
||||
"""Positive control: an active project member still sees the cycle."""
|
||||
response = session_client.get(CYCLES_URL.format(slug=workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
ids = {str(row["id"]) for row in response.data}
|
||||
assert str(cycle.id) in ids, f"Expected cycle {cycle.id} in {response.data!r}"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_modules_visible_to_project_member(self, session_client, workspace, module):
|
||||
"""Positive control: an active project member still sees the module."""
|
||||
response = session_client.get(MODULES_URL.format(slug=workspace.slug))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
ids = {str(row["id"]) for row in response.data}
|
||||
assert str(module.id) in ids, f"Expected module {module.id} in {response.data!r}"
|
||||
@@ -0,0 +1,209 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for ``WorkspaceFileAssetEndpoint`` project-scoping.
|
||||
|
||||
Regression coverage for GHSA-h7mc-p9mm-2r4w / GHSA-cjph-cgm5-8pw8 (WEB-8066),
|
||||
an incomplete fix of the GHSA-qw87 asset-IDOR cluster.
|
||||
|
||||
The endpoint is authorized at the WORKSPACE level, so any workspace member or
|
||||
guest previously reached ``get``/``patch``/``delete`` for a project-bound asset
|
||||
(issue attachment / description, comment description, page description) even
|
||||
when they were not a member of that asset's project. The fix requires an active
|
||||
``ProjectMember`` of ``asset.project_id`` for project-bound assets, while
|
||||
leaving workspace-level assets (WORKSPACE_LOGO, USER_AVATAR, USER_COVER, whose
|
||||
``project_id`` is NULL) accessible to any workspace member.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import (
|
||||
FileAsset,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
S3_STORAGE_PATH = "plane.app.views.asset.v2.S3Storage"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""A project in the fixture workspace; ``create_user`` is an active member."""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_user(db):
|
||||
"""A user who is a workspace member but NOT a member of ``project``."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"outsider-{unique_id}@plane.so",
|
||||
username=f"outsider_{unique_id}",
|
||||
first_name="Outsider",
|
||||
last_name="User",
|
||||
)
|
||||
user.set_password("test-password")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_client(db, workspace, outsider_user):
|
||||
"""Session client for a workspace member who is not in ``project``."""
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=workspace, member=outsider_user, role=15
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=outsider_user)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_asset(db, workspace, project, create_user):
|
||||
"""An uploaded issue attachment that belongs to ``project``."""
|
||||
return FileAsset.objects.create(
|
||||
attributes={"name": "secret.pdf", "type": "application/pdf", "size": 1024},
|
||||
asset=f"{workspace.id}/secret.pdf",
|
||||
size=1024,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
created_by=create_user,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=True,
|
||||
storage_metadata={"size": 1024},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_logo_asset(db, workspace, create_user):
|
||||
"""A workspace-level asset (project_id is NULL) — exempt from project scope."""
|
||||
return FileAsset.objects.create(
|
||||
attributes={"name": "logo.png", "type": "image/png", "size": 256},
|
||||
asset=f"{workspace.id}/logo.png",
|
||||
size=256,
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
entity_type=FileAsset.EntityTypeContext.WORKSPACE_LOGO,
|
||||
is_uploaded=True,
|
||||
storage_metadata={"size": 256},
|
||||
)
|
||||
|
||||
|
||||
def detail_url(slug, asset_id):
|
||||
return f"/api/assets/v2/workspaces/{slug}/{asset_id}/"
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWorkspaceFileAssetProjectScope:
|
||||
"""A workspace member who is not in the asset's project must be blocked."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_project_asset_denied_for_non_project_member(
|
||||
self, outsider_client, workspace, project_asset
|
||||
):
|
||||
"""GET on a project asset by a non-project-member must 403, not mint a
|
||||
presigned download URL."""
|
||||
url = detail_url(workspace.slug, project_asset.id)
|
||||
|
||||
with mock.patch(S3_STORAGE_PATH) as mock_storage:
|
||||
mock_storage.return_value.generate_presigned_url.return_value = (
|
||||
"https://signed.example/download"
|
||||
)
|
||||
response = outsider_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
mock_storage.return_value.generate_presigned_url.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_project_asset_denied_for_non_project_member(
|
||||
self, outsider_client, workspace, project_asset
|
||||
):
|
||||
"""PATCH on a project asset by a non-project-member must 403 and leave
|
||||
the asset untouched."""
|
||||
url = detail_url(workspace.slug, project_asset.id)
|
||||
project_asset.is_uploaded = False
|
||||
project_asset.save(update_fields=["is_uploaded"])
|
||||
|
||||
response = outsider_client.patch(
|
||||
url, {"attributes": {"name": "hacked.pdf"}}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
project_asset.refresh_from_db()
|
||||
assert project_asset.is_uploaded is False
|
||||
assert project_asset.attributes.get("name") == "secret.pdf"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_asset_denied_for_non_project_member(
|
||||
self, outsider_client, workspace, project_asset
|
||||
):
|
||||
"""DELETE on a project asset by a non-project-member must 403 and must
|
||||
not soft-delete the asset."""
|
||||
url = detail_url(workspace.slug, project_asset.id)
|
||||
|
||||
response = outsider_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
project_asset.refresh_from_db()
|
||||
assert project_asset.is_deleted is False
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_project_asset_allowed_for_project_member(
|
||||
self, session_client, workspace, project_asset
|
||||
):
|
||||
"""Positive control: an active project member can still download the
|
||||
asset, so the fix does not over-block legitimate callers."""
|
||||
url = detail_url(workspace.slug, project_asset.id)
|
||||
|
||||
with mock.patch(S3_STORAGE_PATH) as mock_storage:
|
||||
mock_storage.return_value.generate_presigned_url.return_value = (
|
||||
"https://signed.example/download"
|
||||
)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
mock_storage.return_value.generate_presigned_url.assert_called_once()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_workspace_level_asset_allowed_for_non_project_member(
|
||||
self, outsider_client, workspace, workspace_logo_asset
|
||||
):
|
||||
"""Exemption control: a workspace-level asset (project_id NULL) stays
|
||||
accessible to any workspace member."""
|
||||
url = detail_url(workspace.slug, workspace_logo_asset.id)
|
||||
|
||||
with mock.patch(S3_STORAGE_PATH) as mock_storage:
|
||||
mock_storage.return_value.generate_presigned_url.return_value = (
|
||||
"https://signed.example/download"
|
||||
)
|
||||
response = outsider_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
mock_storage.return_value.generate_presigned_url.assert_called_once()
|
||||
@@ -0,0 +1,83 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import User, WorkspaceMember
|
||||
from plane.db.models.workspace import WorkspaceUserPreference
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWorkspaceUserPreferencePatch:
|
||||
"""Contract tests for the sidebar preference PATCH endpoint.
|
||||
|
||||
Regression coverage for #9260: ``patch`` filtered ``WorkspaceUserPreference``
|
||||
by ``key``/``workspace__slug`` only, so in a workspace with multiple members
|
||||
``.first()`` (ordered by ``-created_at``) could return — and mutate — another
|
||||
member's preference row instead of the requesting user's.
|
||||
"""
|
||||
|
||||
KEY = WorkspaceUserPreference.UserPreferenceKeys.ANALYTICS.value
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_only_updates_requesting_users_preference(self, session_client, create_user, workspace):
|
||||
"""A member's PATCH must update only their own preference, never another member's."""
|
||||
# A second, more-recently-active member of the same workspace.
|
||||
other_user = User.objects.create(
|
||||
email="other@plane.so", username="other_user", first_name="Other", last_name="User"
|
||||
)
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=other_user, role=15)
|
||||
|
||||
own_pref = WorkspaceUserPreference.objects.create(
|
||||
workspace=workspace, user=create_user, key=self.KEY, is_pinned=False, sort_order=100
|
||||
)
|
||||
other_pref = WorkspaceUserPreference.objects.create(
|
||||
workspace=workspace, user=other_user, key=self.KEY, is_pinned=False, sort_order=200
|
||||
)
|
||||
|
||||
# Force the other member's row to sort first under the model's ``-created_at``
|
||||
# ordering, so an unscoped ``.first()`` would deterministically pick it.
|
||||
now = timezone.now()
|
||||
WorkspaceUserPreference.objects.filter(pk=own_pref.pk).update(created_at=now)
|
||||
WorkspaceUserPreference.objects.filter(pk=other_pref.pk).update(created_at=now + timedelta(minutes=1))
|
||||
|
||||
url = reverse("workspace-user-preference", kwargs={"slug": workspace.slug})
|
||||
response = session_client.patch(
|
||||
url, [{"key": self.KEY, "is_pinned": True, "sort_order": 999}], format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
own_pref.refresh_from_db()
|
||||
other_pref.refresh_from_db()
|
||||
|
||||
# The requesting user's preference is updated...
|
||||
assert own_pref.is_pinned is True
|
||||
assert own_pref.sort_order == 999
|
||||
# ...and the other member's preference is left untouched.
|
||||
assert other_pref.is_pinned is False
|
||||
assert other_pref.sort_order == 200
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_updates_own_preference(self, session_client, create_user, workspace):
|
||||
"""Baseline: a member's PATCH persists changes to their own preference row."""
|
||||
preference = WorkspaceUserPreference.objects.create(
|
||||
workspace=workspace, user=create_user, key=self.KEY, is_pinned=False, sort_order=100
|
||||
)
|
||||
|
||||
url = reverse("workspace-user-preference", kwargs={"slug": workspace.slug})
|
||||
response = session_client.patch(
|
||||
url, [{"key": self.KEY, "is_pinned": True, "sort_order": 42}], format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
preference.refresh_from_db()
|
||||
assert preference.is_pinned is True
|
||||
assert preference.sort_order == 42
|
||||
Reference in New Issue
Block a user