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

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View 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.

View 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.

View 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,
)

View 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

View 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

View 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

View File

@@ -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]

View 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

View 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}"
)

View 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()

View 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

View 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

View File

@@ -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

View 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()

View 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,
)

View 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.

View 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

View 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

View 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

View 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"
)

View File

@@ -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}"
)

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View 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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View 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

View File

@@ -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}"

View File

@@ -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()

View File

@@ -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