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