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