snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

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

View File

@@ -0,0 +1,143 @@
# Plane Tests
This directory contains tests for the Plane application. The tests are organized using pytest.
## Test Structure
Tests are organized into the following categories:
- **Unit tests**: Test individual functions or classes in isolation.
- **Contract tests**: Test interactions between components and verify API contracts are fulfilled.
- **API tests**: Test the external API endpoints (under `/api/v1/`).
- **App tests**: Test the web application API endpoints (under `/api/`).
- **Smoke tests**: Basic tests to verify that the application runs correctly.
## API vs App Endpoints
Plane has two types of API endpoints:
1. **External API** (`plane.api`):
- Available at `/api/v1/` endpoint
- Uses API key authentication (X-Api-Key header)
- Designed for external API contracts and third-party access
- Tests use the `api_key_client` fixture for authentication
- Test files are in `contract/api/`
2. **Web App API** (`plane.app`):
- Available at `/api/` endpoint
- Uses session-based authentication (CSRF disabled)
- Designed for the web application frontend
- Tests use the `session_client` fixture for authentication
- Test files are in `contract/app/`
## Running Tests
To run all tests:
```bash
python -m pytest
```
To run specific test categories:
```bash
# Run unit tests
python -m pytest plane/tests/unit/
# Run API contract tests
python -m pytest plane/tests/contract/api/
# Run App contract tests
python -m pytest plane/tests/contract/app/
# Run smoke tests
python -m pytest plane/tests/smoke/
```
For convenience, we also provide a helper script:
```bash
# Run all tests
./run_tests.py
# Run only unit tests
./run_tests.py -u
# Run contract tests with coverage report
./run_tests.py -c -o
# Run tests in parallel
./run_tests.py -p
```
## Fixtures
The following fixtures are available for testing:
- `api_client`: Unauthenticated API client
- `create_user`: Creates a test user
- `api_token`: API token for the test user
- `api_key_client`: API client with API key authentication (for external API tests)
- `session_client`: API client with session authentication (for app API tests)
- `plane_server`: Live Django test server for HTTP-based smoke tests
## Writing Tests
When writing tests, follow these guidelines:
1. Place tests in the appropriate directory based on their type.
2. Use the correct client fixture based on the API being tested:
- For external API (`/api/v1/`), use `api_key_client`
- For web app API (`/api/`), use `session_client`
- For smoke tests with real HTTP, use `plane_server`
3. Use the correct URL namespace when reverse-resolving URLs:
- For external API, use `reverse("api:endpoint_name")`
- For web app API, use `reverse("endpoint_name")`
4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database.
5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests.
## Test Fixtures
Common fixtures are defined in:
- `conftest.py`: General fixtures for authentication, database access, etc.
- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery)
- `factories.py`: Test factories for easy model instance creation
## Best Practices
When writing tests, follow these guidelines:
1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods.
2. **Add markers to categorize tests**:
```python
@pytest.mark.unit
@pytest.mark.contract
@pytest.mark.smoke
```
3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code.
4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies.
5. **Write focused tests** that verify one specific behavior or edge case.
6. **Keep test files small and organized** by logical components or endpoints.
7. **Target 90% code coverage** for models, serializers, and business logic.
## External Dependencies
Tests for components that interact with external services should:
1. Use the `mock_redis`, `mock_elasticsearch`, and `mock_celery` fixtures for unit and most contract tests.
2. For more comprehensive contract tests, use Docker-based test containers (optional).
## Coverage Reports
Generate a coverage report with:
```bash
python -m pytest --cov=plane --cov-report=term --cov-report=html
```
This creates an HTML report in the `htmlcov/` directory.
## Migration from Old Tests
Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories.

View File

@@ -0,0 +1,151 @@
# Testing Guide for Plane
This guide explains how to write tests for Plane using our pytest-based testing strategy.
## Test Categories
We divide tests into three categories:
1. **Unit Tests**: Testing individual components in isolation.
2. **Contract Tests**: Testing API endpoints and verifying contracts between components.
3. **Smoke Tests**: Basic end-to-end tests for critical flows.
## Writing Unit Tests
Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing:
- `tests/unit/models/` - For model tests
- `tests/unit/serializers/` - For serializer tests
- `tests/unit/utils/` - For utility function tests
### Example Unit Test:
```python
import pytest
from plane.api.serializers import MySerializer
@pytest.mark.unit
class TestMySerializer:
def test_serializer_valid_data(self):
# Create input data
data = {"field1": "value1", "field2": 42}
# Initialize the serializer
serializer = MySerializer(data=data)
# Validate
assert serializer.is_valid()
# Check validated data
assert serializer.validated_data["field1"] == "value1"
assert serializer.validated_data["field2"] == 42
```
## Writing Contract Tests
Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints.
### Example Contract Test:
```python
import pytest
from django.urls import reverse
from rest_framework import status
@pytest.mark.contract
class TestMyEndpoint:
@pytest.mark.django_db
def test_my_endpoint_get(self, auth_client):
# Get the URL
url = reverse("my-endpoint")
# Make request
response = auth_client.get(url)
# Check response
assert response.status_code == status.HTTP_200_OK
assert "data" in response.data
```
## Writing Smoke Tests
Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server.
### Example Smoke Test:
```python
import pytest
import requests
@pytest.mark.smoke
class TestCriticalFlow:
@pytest.mark.django_db
def test_login_flow(self, plane_server, create_user, user_data):
# Get login URL
url = f"{plane_server.url}/api/auth/signin/"
# Test login
response = requests.post(
url,
json={
"email": user_data["email"],
"password": user_data["password"]
}
)
# Verify
assert response.status_code == 200
data = response.json()
assert "access_token" in data
```
## Useful Fixtures
Our test setup provides several useful fixtures:
1. `api_client`: An unauthenticated DRF APIClient
2. `api_key_client`: API client with API key authentication (for external API tests)
3. `session_client`: API client with session authentication (for web app API tests)
4. `create_user`: Creates and returns a test user
5. `mock_redis`: Mocks Redis interactions
6. `mock_elasticsearch`: Mocks Elasticsearch interactions
7. `mock_celery`: Mocks Celery task execution
## Using Factory Boy
For more complex test data setup, use the provided factories:
```python
from plane.tests.factories import UserFactory, WorkspaceFactory
# Create a user
user = UserFactory()
# Create a workspace with a specific owner
workspace = WorkspaceFactory(owner=user)
# Create multiple objects
users = UserFactory.create_batch(5)
```
## Running Tests
Use pytest to run tests:
```bash
# Run all tests
python -m pytest
# Run only unit tests with coverage
python -m pytest -m unit --cov=plane
```
## Best Practices
1. **Keep tests small and focused** - Each test should verify one specific behavior.
2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.).
3. **Mock external dependencies** - Use the provided mock fixtures.
4. **Use factories** - For complex data setup, use factories.
5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself.
6. **Write readable assertions** - Use plain `assert` statements with clear messaging.
7. **Focus on coverage** - Aim for ≥90% code coverage for critical components.

View File

@@ -0,0 +1,5 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Test package initialization

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.tests"

View File

@@ -0,0 +1,140 @@
# 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.test import APIClient
from pytest_django.fixtures import django_db_setup
from plane.db.models import User, Workspace, WorkspaceMember
from plane.db.models.api import APIToken
@pytest.fixture(scope="session")
def django_db_setup(django_db_setup): # noqa: F811
"""Set up the Django database for the test session"""
pass
@pytest.fixture
def api_client():
"""Return an unauthenticated API client"""
return APIClient()
@pytest.fixture
def user_data():
"""Return standard user data for tests"""
return {
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User",
}
@pytest.fixture
def create_user(db, user_data):
"""Create and return a user instance"""
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"],
)
user.set_password(user_data["password"])
user.save()
return user
@pytest.fixture
def api_token(db, create_user):
"""Create and return an API token for testing the external API"""
token = APIToken.objects.create(
user=create_user,
label="Test API Token",
token="test-api-token-12345",
)
return token
@pytest.fixture
def api_key_client(api_client, api_token):
"""Return an API key authenticated client for external API testing"""
api_client.credentials(HTTP_X_API_KEY=api_token.token)
return api_client
@pytest.fixture
def session_client(api_client, create_user):
"""Return a session authenticated API client for app API testing, which is what plane.app uses"""
api_client.force_authenticate(user=create_user)
return api_client
@pytest.fixture
def create_bot_user(db):
"""Create and return a bot user instance"""
from uuid import uuid4
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"bot-{unique_id}@plane.so",
username=f"bot_user_{unique_id}",
first_name="Bot",
last_name="User",
is_bot=True,
)
user.set_password("bot@123")
user.save()
return user
@pytest.fixture
def api_token_data():
"""Return sample API token data for testing"""
from django.utils import timezone
from datetime import timedelta
return {
"label": "Test API Token",
"description": "Test description for API token",
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
}
@pytest.fixture
def create_api_token_for_user(db, create_user):
"""Create and return an API token for a specific user"""
return APIToken.objects.create(
label="Test Token",
description="Test token description",
user=create_user,
user_type=0,
)
@pytest.fixture
def plane_server(live_server):
"""
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
@pytest.fixture
def workspace(create_user):
"""
Create a new workspace and return the
corresponding Workspace model instance.
"""
# Create the workspace using the model
created_workspace = Workspace.objects.create(
name="Test Workspace",
owner=create_user,
slug="test-workspace",
)
WorkspaceMember.objects.create(workspace=created_workspace, member=create_user, role=20)
return created_workspace

View File

@@ -0,0 +1,64 @@
# 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 unittest.mock import MagicMock, patch
@pytest.fixture
def mock_redis():
"""
Mock Redis for testing without actual Redis connection.
This fixture patches the redis_instance function to return a MagicMock
that behaves like a Redis client.
"""
mock_redis_client = MagicMock()
# Configure the mock to handle common Redis operations
mock_redis_client.get.return_value = None
mock_redis_client.set.return_value = True
mock_redis_client.delete.return_value = True
mock_redis_client.exists.return_value = 0
mock_redis_client.ttl.return_value = -1
# Start the patch
with patch("plane.settings.redis.redis_instance", return_value=mock_redis_client):
yield mock_redis_client
@pytest.fixture
def mock_elasticsearch():
"""
Mock Elasticsearch for testing without actual ES connection.
This fixture patches Elasticsearch to return a MagicMock
that behaves like an Elasticsearch client.
"""
mock_es_client = MagicMock()
# Configure the mock to handle common ES operations
mock_es_client.indices.exists.return_value = True
mock_es_client.indices.create.return_value = {"acknowledged": True}
mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}}
mock_es_client.index.return_value = {"_id": "test_id", "result": "created"}
mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"}
mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"}
# Start the patch
with patch("elasticsearch.Elasticsearch", return_value=mock_es_client):
yield mock_es_client
@pytest.fixture
def mock_celery():
"""
Mock Celery for testing without actual task execution.
This fixture patches Celery's task.delay() to prevent actual task execution.
"""
# Start the patch
with patch("celery.app.task.Task.delay") as mock_delay:
mock_delay.return_value = MagicMock(id="mock-task-id")
yield mock_delay

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,44 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Contract tests for external API key authentication.
End-to-end proof that an API key cannot be used to access the API once the
owning user account has been deactivated.
"""
import pytest
from rest_framework import status
@pytest.mark.contract
class TestAPIKeyAuthenticationContract:
"""Test API key authentication behaviour against a live endpoint."""
USERS_ME_URL = "/api/v1/users/me/"
@pytest.mark.django_db
def test_active_user_can_access_with_api_key(self, api_key_client):
response = api_key_client.get(self.USERS_ME_URL)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_deactivated_user_cannot_access_with_api_key(
self, api_key_client, create_user
):
# The account is disabled after the API key was generated.
create_user.is_active = False
create_user.save()
response = api_key_client.get(self.USERS_ME_URL)
# Access is denied once the account is deactivated. APIKeyAuthentication
# does not set a WWW-Authenticate header, so DRF surfaces the
# AuthenticationFailed as 403 Forbidden rather than 401.
assert response.status_code in (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)

View File

@@ -0,0 +1,385 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from rest_framework import status
from django.utils import timezone
from datetime import timedelta
from uuid import uuid4
from plane.db.models import Cycle, Project, ProjectMember
@pytest.fixture
def project(db, workspace, create_user):
"""Create a test project with the user as a member"""
project = Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
cycle_view=True,
)
ProjectMember.objects.create(
project=project,
member=create_user,
role=20, # Admin role
is_active=True,
)
return project
@pytest.fixture
def cycle_data():
"""Sample cycle data for tests"""
return {
"name": "Test Cycle",
"description": "A test cycle for unit tests",
}
@pytest.fixture
def draft_cycle_data():
"""Sample draft cycle data (no dates)"""
return {
"name": "Draft Cycle",
"description": "A draft cycle without dates",
}
@pytest.fixture
def create_cycle(db, project, create_user):
"""Create a test cycle"""
return Cycle.objects.create(
name="Existing Cycle",
description="An existing cycle",
start_date=timezone.now() + timedelta(days=1),
end_date=timezone.now() + timedelta(days=7),
project=project,
workspace=project.workspace,
owned_by=create_user,
)
@pytest.mark.contract
class TestCycleListCreateAPIEndpoint:
"""Test Cycle List and Create API Endpoint"""
def get_cycle_url(self, workspace_slug, project_id):
"""Helper to get cycle endpoint URL"""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/"
@pytest.mark.django_db
def test_create_cycle_success(self, api_key_client, workspace, project, cycle_data):
"""Test successful cycle creation"""
url = self.get_cycle_url(workspace.slug, project.id)
response = api_key_client.post(url, cycle_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert Cycle.objects.count() == 1
created_cycle = Cycle.objects.first()
assert created_cycle.name == cycle_data["name"]
assert created_cycle.description == cycle_data["description"]
assert created_cycle.project == project
assert created_cycle.owned_by_id is not None
@pytest.mark.django_db
def test_create_cycle_invalid_data(self, api_key_client, workspace, project):
"""Test cycle creation with invalid data"""
url = self.get_cycle_url(workspace.slug, project.id)
# Test with empty data
response = api_key_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Test with missing name
response = api_key_client.post(url, {"description": "Test cycle"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_create_cycle_invalid_date_combination(self, api_key_client, workspace, project):
"""Test cycle creation with invalid date combination (only start_date)"""
url = self.get_cycle_url(workspace.slug, project.id)
invalid_data = {
"name": "Invalid Cycle",
"start_date": (timezone.now() + timedelta(days=1)).isoformat(),
# Missing end_date
}
response = api_key_client.post(url, invalid_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Both start date and end date are either required or are to be null" in response.data["error"]
@pytest.mark.django_db
def test_create_cycle_with_external_id(self, api_key_client, workspace, project):
"""Test creating cycle with external ID"""
url = self.get_cycle_url(workspace.slug, project.id)
cycle_data = {
"name": "External Cycle",
"description": "A cycle with external ID",
"external_id": "ext-123",
"external_source": "github",
}
response = api_key_client.post(url, cycle_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
created_cycle = Cycle.objects.first()
assert created_cycle.external_id == "ext-123"
assert created_cycle.external_source == "github"
@pytest.mark.django_db
def test_create_cycle_duplicate_external_id(self, api_key_client, workspace, project, create_user):
"""Test creating cycle with duplicate external ID"""
url = self.get_cycle_url(workspace.slug, project.id)
# Create first cycle
Cycle.objects.create(
name="First Cycle",
project=project,
workspace=workspace,
external_id="ext-123",
external_source="github",
owned_by=create_user,
)
# Try to create second cycle with same external ID
cycle_data = {
"name": "Second Cycle",
"external_id": "ext-123",
"external_source": "github",
"owned_by": create_user.id,
}
response = api_key_client.post(url, cycle_data, format="json")
assert response.status_code == status.HTTP_409_CONFLICT
assert "same external id" in response.data["error"]
@pytest.mark.django_db
def test_list_cycles_success(self, api_key_client, workspace, project, create_cycle, create_user):
"""Test successful cycle listing"""
url = self.get_cycle_url(workspace.slug, project.id)
# Create additional cycles
Cycle.objects.create(
name="Cycle 2",
project=project,
workspace=workspace,
start_date=timezone.now() + timedelta(days=10),
end_date=timezone.now() + timedelta(days=17),
owned_by=create_user,
)
Cycle.objects.create(
name="Cycle 3",
project=project,
workspace=workspace,
start_date=timezone.now() + timedelta(days=20),
end_date=timezone.now() + timedelta(days=27),
owned_by=create_user,
)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
assert len(response.data["results"]) == 3 # Including create_cycle fixture
@pytest.mark.django_db
def test_list_cycles_with_view_filter(self, api_key_client, workspace, project, create_user):
"""Test cycle listing with different view filters"""
url = self.get_cycle_url(workspace.slug, project.id)
# Create cycles in different states
now = timezone.now()
# Current cycle (started but not ended)
Cycle.objects.create(
name="Current Cycle",
project=project,
workspace=workspace,
start_date=now - timedelta(days=1),
end_date=now + timedelta(days=6),
owned_by=create_user,
)
# Upcoming cycle
Cycle.objects.create(
name="Upcoming Cycle",
project=project,
workspace=workspace,
start_date=now + timedelta(days=1),
end_date=now + timedelta(days=8),
owned_by=create_user,
)
# Completed cycle
Cycle.objects.create(
name="Completed Cycle",
project=project,
workspace=workspace,
start_date=now - timedelta(days=10),
end_date=now - timedelta(days=3),
owned_by=create_user,
)
# Draft cycle
Cycle.objects.create(
name="Draft Cycle",
project=project,
workspace=workspace,
owned_by=create_user,
)
# Test current cycles
response = api_key_client.get(url, {"cycle_view": "current"})
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]["name"] == "Current Cycle"
# Test upcoming cycles
response = api_key_client.get(url, {"cycle_view": "upcoming"})
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["name"] == "Upcoming Cycle"
# Test completed cycles
response = api_key_client.get(url, {"cycle_view": "completed"})
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["name"] == "Completed Cycle"
# Test draft cycles
response = api_key_client.get(url, {"cycle_view": "draft"})
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["name"] == "Draft Cycle"
@pytest.mark.contract
class TestCycleDetailAPIEndpoint:
"""Test Cycle Detail API Endpoint"""
def get_cycle_detail_url(self, workspace_slug, project_id, cycle_id):
"""Helper to get cycle detail endpoint URL"""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/"
@pytest.mark.django_db
def test_get_cycle_success(self, api_key_client, workspace, project, create_cycle):
"""Test successful cycle retrieval"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert str(response.data["id"]) == str(create_cycle.id)
assert response.data["name"] == create_cycle.name
assert response.data["description"] == create_cycle.description
@pytest.mark.django_db
def test_get_cycle_not_found(self, api_key_client, workspace, project):
"""Test getting non-existent cycle"""
fake_id = uuid4()
url = self.get_cycle_detail_url(workspace.slug, project.id, fake_id)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_update_cycle_success(self, api_key_client, workspace, project, create_cycle):
"""Test successful cycle update"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
update_data = {
"name": f"Updated Cycle {uuid4()}",
"description": "Updated description",
}
response = api_key_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_200_OK
create_cycle.refresh_from_db()
assert create_cycle.name == update_data["name"]
assert create_cycle.description == update_data["description"]
@pytest.mark.django_db
def test_update_cycle_invalid_data(self, api_key_client, workspace, project, create_cycle):
"""Test cycle update with invalid data"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
update_data = {"name": ""}
response = api_key_client.patch(url, update_data, format="json")
# This might be 400 if name is required, or 200 if empty names are allowed
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
@pytest.mark.django_db
def test_update_cycle_with_external_id_conflict(
self, api_key_client, workspace, project, create_cycle, create_user
):
"""Test cycle update with conflicting external ID"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
# Create another cycle with external ID
Cycle.objects.create(
name="Another Cycle",
project=project,
workspace=workspace,
external_id="ext-456",
external_source="github",
owned_by=create_user,
)
# Try to update cycle with same external ID
update_data = {
"external_id": "ext-456",
"external_source": "github",
}
response = api_key_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_409_CONFLICT
assert "same external id" in response.data["error"]
@pytest.mark.django_db
def test_delete_cycle_success(self, api_key_client, workspace, project, create_cycle):
"""Test successful cycle deletion"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
response = api_key_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Cycle.objects.filter(id=create_cycle.id).exists()
@pytest.mark.django_db
def test_cycle_metrics_annotation(self, api_key_client, workspace, project, create_cycle):
"""Test that cycle includes issue metrics annotations"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
# Check that metrics are included in response
cycle_data = response.data
assert "total_issues" in cycle_data
assert "completed_issues" in cycle_data
assert "cancelled_issues" in cycle_data
assert "started_issues" in cycle_data
assert "unstarted_issues" in cycle_data
assert "backlog_issues" in cycle_data
# All should be 0 for a new cycle
assert cycle_data["total_issues"] == 0
assert cycle_data["completed_issues"] == 0
assert cycle_data["cancelled_issues"] == 0
assert cycle_data["started_issues"] == 0
assert cycle_data["unstarted_issues"] == 0
assert cycle_data["backlog_issues"] == 0

View File

@@ -0,0 +1,72 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the cycles-lite endpoint.
GET /api/v1/workspaces/<slug>/projects/<project_id>/cycles-lite/
"""
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import Cycle, Project, ProjectMember
def _url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/cycles-lite/"
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Cycle Lite Project",
identifier="CLP",
workspace=workspace,
created_by=create_user,
cycle_view=True,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.fixture
def cycles(db, project, create_user):
now = timezone.now()
active = Cycle.objects.create(
name="Active Cycle",
project=project,
workspace=project.workspace,
owned_by=create_user,
start_date=now,
end_date=now + timedelta(days=7),
)
archived = Cycle.objects.create(
name="Archived Cycle",
project=project,
workspace=project.workspace,
owned_by=create_user,
archived_at=now,
)
return {"active": active, "archived": archived}
@pytest.mark.contract
class TestCyclesLite:
@pytest.mark.django_db
def test_paginated_and_excludes_archived(self, api_key_client, workspace, project, cycles):
response = api_key_client.get(_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
names = {item["name"] for item in response.data["results"]}
assert "Active Cycle" in names
assert "Archived Cycle" not in names

View File

@@ -0,0 +1,143 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the public REST API ``GenericAssetEndpoint``.
Regression coverage for the cross-workspace asset IDOR (the unfixed
external-API sibling of CVE-2026-46558 / GHSA-qw87-v5w3-6vxx). The endpoint
must reject any caller that is not an active member of the workspace named in
the URL slug, regardless of the workspace their Personal Access Token came
from.
"""
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from plane.db.models import FileAsset, User, Workspace, WorkspaceMember
@pytest.fixture
def victim_user(db):
"""A user that owns a separate workspace the attacker is not part of."""
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"victim-{unique_id}@plane.so",
username=f"victim_{unique_id}",
first_name="Victim",
last_name="User",
)
user.set_password("test-password")
user.save()
return user
@pytest.fixture
def victim_workspace(db, victim_user):
"""A workspace whose only active member is ``victim_user``.
The attacker (``create_user``, who authenticates ``api_key_client``) is
deliberately NOT a member here.
"""
workspace = Workspace.objects.create(
name="Victim Workspace",
owner=victim_user,
slug="victim-workspace",
)
WorkspaceMember.objects.create(workspace=workspace, member=victim_user, role=20)
return workspace
@pytest.fixture
def victim_asset(db, victim_workspace, victim_user):
"""An uploaded attachment that lives inside the victim workspace.
``storage_metadata`` is pre-populated so the PATCH handler does not enqueue
the metadata Celery task during the test.
"""
return FileAsset.objects.create(
attributes={"name": "secret.pdf", "type": "application/pdf", "size": 1024},
asset=f"{victim_workspace.id}/secret.pdf",
size=1024,
workspace=victim_workspace,
created_by=victim_user,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
storage_metadata={"size": 1024},
)
@pytest.mark.contract
class TestGenericAssetCrossWorkspaceIDOR:
"""A PAT holder must not reach assets in a workspace they don't belong to."""
def detail_url(self, slug, asset_id):
return f"/api/v1/workspaces/{slug}/assets/{asset_id}/"
def list_url(self, slug):
return f"/api/v1/workspaces/{slug}/assets/"
@pytest.mark.django_db
def test_get_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset):
"""GET on another workspace's asset must be forbidden, not return a
presigned download URL."""
url = self.detail_url(victim_workspace.slug, victim_asset.id)
with mock.patch("plane.api.views.asset.S3Storage") as mock_storage:
mock_storage.return_value.generate_presigned_url.return_value = "https://signed.example/download"
response = api_key_client.get(url)
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
# The S3 download URL must never be minted for a non-member.
mock_storage.return_value.generate_presigned_url.assert_not_called()
@pytest.mark.django_db
def test_post_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace):
"""POST (upload) into another workspace must be forbidden and must not
plant an asset row in the victim workspace."""
url = self.list_url(victim_workspace.slug)
payload = {"name": "evil.pdf", "type": "application/pdf", "size": 1024}
with mock.patch("plane.api.views.asset.S3Storage") as mock_storage:
mock_storage.return_value.generate_presigned_post.return_value = {"url": "x", "fields": {}}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
assert FileAsset.objects.filter(workspace=victim_workspace).count() == 0
@pytest.mark.django_db
def test_patch_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset):
"""PATCH on another workspace's asset must be forbidden and must leave
the asset untouched."""
url = self.detail_url(victim_workspace.slug, victim_asset.id)
response = api_key_client.patch(url, {"is_uploaded": False}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
victim_asset.refresh_from_db()
assert victim_asset.is_uploaded is True
@pytest.mark.django_db
def test_member_can_patch_own_workspace_asset(self, api_key_client, workspace, create_user):
"""Positive control: an active member of the workspace can still update
their own asset, so the fix does not over-block legitimate callers."""
asset = FileAsset.objects.create(
attributes={"name": "mine.pdf", "type": "application/pdf", "size": 10},
asset=f"{workspace.id}/mine.pdf",
size=10,
workspace=workspace,
created_by=create_user,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=False,
storage_metadata={"size": 10},
)
url = self.detail_url(workspace.slug, asset.id)
response = api_key_client.patch(url, {"is_uploaded": True}, format="json")
assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}"
asset.refresh_from_db()
assert asset.is_uploaded is True

View File

@@ -0,0 +1,136 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from rest_framework import status
from plane.celery import app as celery_app
from plane.db.models import Issue, Label, Project, ProjectMember, State, User
@pytest.fixture(autouse=True)
def celery_eager():
"""
Run Celery tasks synchronously in-process instead of publishing to a
broker. There's no RabbitMQ/broker in this local sandbox, and these
tests only care about the HTTP response contract, not async delivery.
"""
original = celery_app.conf.task_always_eager
celery_app.conf.task_always_eager = True
celery_app.conf.task_eager_propagates = False
yield
celery_app.conf.task_always_eager = original
@pytest.fixture
def project(db, workspace, create_user):
"""Create a test project with the user as an admin member and a default state."""
project = Project.objects.create(
name="Test Project", identifier="TP", workspace=workspace, created_by=create_user
)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
State.objects.create(
name="Backlog",
color="#000000",
group="backlog",
default=True,
project=project,
workspace=workspace,
created_by=create_user,
)
return project
@pytest.fixture
def create_issue(db, project, workspace, create_user):
return Issue.objects.create(name="Existing Issue", project=project, workspace=workspace, created_by=create_user)
@pytest.fixture
def outsider_user(db):
"""A user who exists in the workspace/system but is NOT a member of `project`."""
user = User.objects.create(email="outsider@plane.so", username="outsider-user")
user.set_password("outsider-password")
user.save()
return user
@pytest.mark.contract
class TestIssueAssigneeLabelValidationContract:
"""
Contract: creating/updating a work item through the external REST API
(``/api/v1/...``) must reject assignee/label ids that don't belong to the
project with a 400, instead of silently dropping them and returning
200/201. See makeplane/plane#9517.
"""
def get_list_url(self, workspace_slug, project_id):
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/"
def get_detail_url(self, workspace_slug, project_id, issue_id):
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/"
@pytest.mark.django_db
def test_create_with_non_member_assignee_is_rejected(self, api_key_client, workspace, project, outsider_user):
url = self.get_list_url(workspace.slug, project.id)
response = api_key_client.post(
url, {"name": "New Issue", "assignees": [str(outsider_user.id)]}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Issue.objects.filter(name="New Issue").exists()
@pytest.mark.django_db
def test_create_with_foreign_label_is_rejected(self, api_key_client, workspace, project):
other_project = Project.objects.create(name="Other", identifier="OTH", workspace=workspace)
foreign_label = Label.objects.create(name="Foreign", project=other_project)
url = self.get_list_url(workspace.slug, project.id)
response = api_key_client.post(
url, {"name": "New Issue", "labels": [str(foreign_label.id)]}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Issue.objects.filter(name="New Issue").exists()
@pytest.mark.django_db
def test_update_with_non_member_assignee_is_rejected_and_leaves_assignees_unchanged(
self, api_key_client, workspace, project, create_issue, outsider_user
):
url = self.get_detail_url(workspace.slug, project.id, create_issue.id)
response = api_key_client.patch(url, {"assignees": [str(outsider_user.id)]}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert list(create_issue.assignees.all()) == []
@pytest.mark.django_db
def test_create_with_mix_of_valid_and_invalid_assignee_is_rejected_entirely(
self, api_key_client, workspace, project, create_user, outsider_user
):
"""A partially-valid list must reject the whole request, not silently keep only the valid id."""
url = self.get_list_url(workspace.slug, project.id)
response = api_key_client.post(
url,
{"name": "New Issue", "assignees": [str(create_user.id), str(outsider_user.id)]},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Issue.objects.filter(name="New Issue").exists()
@pytest.mark.django_db
def test_create_with_valid_assignee_still_works(self, api_key_client, workspace, project, create_user):
"""Regression guard: a genuinely valid project member must still be assignable."""
url = self.get_list_url(workspace.slug, project.id)
response = api_key_client.post(
url, {"name": "New Issue", "assignees": [str(create_user.id)]}, format="json"
)
assert response.status_code == status.HTTP_201_CREATED
issue = Issue.objects.get(name="New Issue")
assert list(issue.assignees.values_list("id", flat=True)) == [create_user.id]

View File

@@ -0,0 +1,135 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from unittest.mock import patch
import pytest
from rest_framework import status
from plane.db.models import Issue, Project, ProjectMember, State, User
@pytest.fixture
def project(db, workspace, create_user):
"""Create a test project with the user as an admin member and a default state."""
project = Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project,
member=create_user,
role=20, # Admin role
is_active=True,
)
# A default state is required to create work items through the API
State.objects.create(
name="Backlog",
color="#000000",
group="backlog",
default=True,
project=project,
workspace=workspace,
created_by=create_user,
)
return project
@pytest.fixture
def create_issue(db, project, workspace, create_user):
"""Create an existing work item to update/assign in tests."""
return Issue.objects.create(
name="Existing Issue",
project=project,
workspace=workspace,
created_by=create_user,
)
@pytest.fixture
def assignee_user(db):
"""Create a second user that can be assigned to a work item."""
user = User.objects.create(
email="assignee@plane.so",
username="assignee-user",
first_name="Assignee",
last_name="User",
)
user.set_password("assignee-password")
user.save()
return user
@pytest.mark.contract
class TestIssueNotificationContract:
"""
Contract: creating and updating/assigning a work item through the external
REST API (``/api/v1/...``) must trigger notifications, i.e. ``issue_activity``
is dispatched with ``notification=True`` so subscribers and assignees are
notified the same way the web app does. See makeplane/plane#9306.
"""
def get_list_url(self, workspace_slug, project_id):
"""Helper to build the work item list/create endpoint URL."""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/"
def get_detail_url(self, workspace_slug, project_id, issue_id):
"""Helper to build the work item detail endpoint URL."""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/"
@pytest.mark.django_db
def test_create_issue_triggers_notification(self, api_key_client, workspace, project):
"""Creating a work item via the external API dispatches a notifying activity."""
url = self.get_list_url(workspace.slug, project.id)
with patch("plane.api.views.issue.issue_activity") as mock_issue_activity:
response = api_key_client.post(url, {"name": "New Issue"}, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert Issue.objects.filter(name="New Issue").exists()
mock_issue_activity.delay.assert_called_once()
kwargs = mock_issue_activity.delay.call_args.kwargs
assert kwargs["type"] == "issue.activity.created"
assert kwargs["notification"] is True
@pytest.mark.django_db
def test_update_issue_triggers_notification(self, api_key_client, workspace, project, create_issue):
"""Updating a work item via the external API dispatches a notifying activity."""
url = self.get_detail_url(workspace.slug, project.id, create_issue.id)
with patch("plane.api.views.issue.issue_activity") as mock_issue_activity:
response = api_key_client.patch(url, {"name": "Renamed Issue"}, format="json")
assert response.status_code == status.HTTP_200_OK
create_issue.refresh_from_db()
assert create_issue.name == "Renamed Issue"
mock_issue_activity.delay.assert_called_once()
kwargs = mock_issue_activity.delay.call_args.kwargs
assert kwargs["type"] == "issue.activity.updated"
assert kwargs["notification"] is True
@pytest.mark.django_db
def test_assign_issue_triggers_notification(self, api_key_client, workspace, project, create_issue, assignee_user):
"""Assigning a work item via the external API dispatches a notifying activity."""
ProjectMember.objects.create(
project=project,
member=assignee_user,
role=15, # Member role
is_active=True,
)
url = self.get_detail_url(workspace.slug, project.id, create_issue.id)
with patch("plane.api.views.issue.issue_activity") as mock_issue_activity:
response = api_key_client.patch(url, {"assignees": [str(assignee_user.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK
mock_issue_activity.delay.assert_called_once()
kwargs = mock_issue_activity.delay.call_args.kwargs
assert kwargs["type"] == "issue.activity.updated"
assert kwargs["notification"] is True

View File

@@ -0,0 +1,96 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from rest_framework import status
from plane.db.models import Issue, Project, ProjectMember, State
@pytest.fixture
def project(db, workspace, create_user):
"""Create a test project with the requesting user as an active member."""
project = Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project,
member=create_user,
role=20, # Admin
is_active=True,
)
return project
@pytest.fixture
def state(db, workspace, project):
return State.objects.create(
name="Todo",
project=project,
workspace=workspace,
group="backlog",
default=True,
)
@pytest.fixture
def issue(db, workspace, project, state, create_user):
return Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
state=state,
created_by=create_user,
)
@pytest.mark.contract
class TestIssueListOrderByInjection:
"""Regression tests for GHSA-p885-6jpg-cr2p on the work-item list
endpoint: GET /api/v1/workspaces/{slug}/projects/{project_id}/issues/.
The raw ``order_by`` query parameter fell through the endpoint's hardcoded
branch logic to ``issue_queryset.order_by(order_by_param)``, letting an
attacker order by sensitive related columns (blind oracle) or crash the
endpoint with an unknown field (HTTP 500). The fix sanitizes the parameter
against ISSUE_ORDER_BY_ALLOWLIST before the branch logic runs.
"""
def get_url(self, workspace_slug, project_id):
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/"
@pytest.mark.django_db
def test_invalid_order_by_does_not_500(self, api_key_client, workspace, project, issue):
"""Unknown field used to raise FieldError → HTTP 500; now sanitized to
the safe default and returns 200 (DoS half of the advisory)."""
url = self.get_url(workspace.slug, project.id)
response = api_key_client.get(url, {"order_by": "not_a_field"})
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
@pytest.mark.django_db
def test_relational_order_by_injection_does_not_500(self, api_key_client, workspace, project, issue):
"""Ordering by a related-table column (``created_by__password``) used to
reach ``.order_by()`` raw, forming a blind ordering oracle. It is now
neutralized to the safe default. (Deterministic neutralization is
asserted in tests/unit/utils/test_order_by_sanitize.py.)"""
url = self.get_url(workspace.slug, project.id)
response = api_key_client.get(url, {"order_by": "created_by__password"})
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
@pytest.mark.django_db
def test_legitimate_order_by_still_works(self, api_key_client, workspace, project, issue):
"""A valid, allowlisted ordering value continues to return 200 —
the sanitizer must not break legitimate ordering."""
url = self.get_url(workspace.slug, project.id)
for value in ["-created_at", "priority", "state__group", "sequence_id"]:
response = api_key_client.get(url, {"order_by": value})
assert response.status_code == status.HTTP_200_OK, (
f"order_by={value!r} got {response.status_code}: {response.data!r}"
)

View File

@@ -0,0 +1,217 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from rest_framework import status
from uuid import uuid4
from plane.db.models import Label, Project, ProjectMember
@pytest.fixture
def project(db, workspace, create_user):
"""Create a test project with the user as a member"""
project = Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project,
member=create_user,
role=20, # Admin role
is_active=True,
)
return project
@pytest.fixture
def label_data():
"""Sample label data for tests"""
return {
"name": "Test Label",
"color": "#FF5733",
"description": "A test label for unit tests",
}
@pytest.fixture
def create_label(db, project, create_user):
"""Create a test label"""
return Label.objects.create(
name="Existing Label",
color="#00FF00",
description="An existing label",
project=project,
workspace=project.workspace,
created_by=create_user,
)
@pytest.mark.contract
class TestLabelListCreateAPIEndpoint:
"""Test Label List and Create API Endpoint"""
def get_label_url(self, workspace_slug, project_id):
"""Helper to get label endpoint URL"""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/"
@pytest.mark.django_db
def test_create_label_success(self, api_key_client, workspace, project, label_data):
"""Test successful label creation"""
url = self.get_label_url(workspace.slug, project.id)
response = api_key_client.post(url, label_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert Label.objects.count() == 1
created_label = Label.objects.first()
assert created_label.name == label_data["name"]
assert created_label.color == label_data["color"]
assert created_label.description == label_data["description"]
assert created_label.project == project
@pytest.mark.django_db
def test_create_label_invalid_data(self, api_key_client, workspace, project):
"""Test label creation with invalid data"""
url = self.get_label_url(workspace.slug, project.id)
# Test with empty data
response = api_key_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Test with missing name
response = api_key_client.post(url, {"color": "#FF5733"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_create_label_with_external_id(self, api_key_client, workspace, project):
"""Test creating label with external ID"""
url = self.get_label_url(workspace.slug, project.id)
label_data = {
"name": "External Label",
"color": "#FF5733",
"external_id": "ext-123",
"external_source": "github",
}
response = api_key_client.post(url, label_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
created_label = Label.objects.first()
assert created_label.external_id == "ext-123"
assert created_label.external_source == "github"
@pytest.mark.django_db
def test_create_label_duplicate_external_id(self, api_key_client, workspace, project):
"""Test creating label with duplicate external ID"""
url = self.get_label_url(workspace.slug, project.id)
# Create first label
Label.objects.create(
name="First Label",
project=project,
workspace=workspace,
external_id="ext-123",
external_source="github",
)
# Try to create second label with same external ID
label_data = {
"name": "Second Label",
"external_id": "ext-123",
"external_source": "github",
}
response = api_key_client.post(url, label_data, format="json")
assert response.status_code == status.HTTP_409_CONFLICT
assert "same external id" in response.data["error"]
@pytest.mark.django_db
def test_list_labels_success(self, api_key_client, workspace, project, create_label):
"""Test successful label listing"""
url = self.get_label_url(workspace.slug, project.id)
# Create additional labels
Label.objects.create(name="Label 2", project=project, workspace=workspace, color="#00FF00")
Label.objects.create(name="Label 3", project=project, workspace=workspace, color="#0000FF")
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
assert len(response.data["results"]) == 3 # Including create_label fixture
@pytest.mark.contract
class TestLabelDetailAPIEndpoint:
"""Test Label Detail API Endpoint"""
def get_label_detail_url(self, workspace_slug, project_id, label_id):
"""Helper to get label detail endpoint URL"""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/{label_id}/"
@pytest.mark.django_db
def test_get_label_success(self, api_key_client, workspace, project, create_label):
"""Test successful label retrieval"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["id"] == create_label.id
assert response.data["name"] == create_label.name
assert response.data["color"] == create_label.color
@pytest.mark.django_db
def test_get_label_not_found(self, api_key_client, workspace, project):
"""Test getting non-existent label"""
from uuid import uuid4
fake_id = uuid4()
url = self.get_label_detail_url(workspace.slug, project.id, fake_id)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_update_label_success(self, api_key_client, workspace, project, create_label):
"""Test successful label update"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
update_data = {
"name": f"Updated Label {uuid4()}",
}
response = api_key_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_200_OK
create_label.refresh_from_db()
assert create_label.name == update_data["name"]
@pytest.mark.django_db
def test_update_label_invalid_data(self, api_key_client, workspace, project, create_label):
"""Test label update with invalid data"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
update_data = {"name": ""}
response = api_key_client.patch(url, update_data, format="json")
# This might be 400 if name is required, or 200 if empty names are allowed
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
@pytest.mark.django_db
def test_delete_label_success(self, api_key_client, workspace, project, create_label):
"""Test successful label deletion"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
response = api_key_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Label.objects.filter(id=create_label.id).exists()

View File

@@ -0,0 +1,102 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the workspace/project members-lite endpoints.
GET /api/v1/workspaces/<slug>/members-lite/
GET /api/v1/workspaces/<slug>/projects/<project_id>/project-members-lite/
"""
import pytest
from rest_framework import status
from plane.db.models import Project, ProjectMember
_LITE_MEMBER_FIELDS = (
"id",
"first_name",
"last_name",
"email",
"avatar",
"avatar_url",
"display_name",
"role",
"is_active",
"is_bot",
)
def _ws_url(slug):
return f"/api/v1/workspaces/{slug}/members-lite/"
def _project_url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/project-members-lite/"
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Members Lite Project",
identifier="MLP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.mark.contract
class TestWorkspaceMembersLite:
@pytest.mark.django_db
def test_returns_paginated_member(self, api_key_client, workspace):
response = api_key_client.get(_ws_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
emails = {item["email"] for item in response.data["results"]}
assert "test@plane.so" in emails
@pytest.mark.django_db
def test_lite_member_shape(self, api_key_client, workspace):
response = api_key_client.get(_ws_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
item = response.data["results"][0]
for key in _LITE_MEMBER_FIELDS:
assert key in item
# The requesting user is the workspace owner (admin role = 20).
assert item["role"] == 20
@pytest.mark.django_db
def test_unknown_workspace_is_rejected(self, api_key_client):
response = api_key_client.get(_ws_url("does-not-exist"))
assert response.status_code in (
status.HTTP_400_BAD_REQUEST,
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND,
)
@pytest.mark.contract
class TestProjectMembersLite:
@pytest.mark.django_db
def test_returns_paginated_member(self, api_key_client, workspace, project):
response = api_key_client.get(_project_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
emails = {item["email"] for item in response.data["results"]}
assert "test@plane.so" in emails
@pytest.mark.django_db
def test_lite_member_shape(self, api_key_client, workspace, project):
response = api_key_client.get(_project_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
item = response.data["results"][0]
for key in _LITE_MEMBER_FIELDS:
assert key in item

View File

@@ -0,0 +1,61 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the modules-lite endpoint.
GET /api/v1/workspaces/<slug>/projects/<project_id>/modules-lite/
"""
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import Module, Project, ProjectMember
def _url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/modules-lite/"
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Module Lite Project",
identifier="MDL",
workspace=workspace,
created_by=create_user,
module_view=True,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.fixture
def modules(db, project):
active = Module.objects.create(name="Active Module", project=project, workspace=project.workspace)
archived = Module.objects.create(
name="Archived Module",
project=project,
workspace=project.workspace,
archived_at=timezone.now(),
)
return {"active": active, "archived": archived}
@pytest.mark.contract
class TestModulesLite:
@pytest.mark.django_db
def test_paginated_and_excludes_archived(self, api_key_client, workspace, project, modules):
response = api_key_client.get(_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
names = {item["name"] for item in response.data["results"]}
assert "Active Module" in names
assert "Archived Module" not in names

View File

@@ -0,0 +1,101 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for ``ProjectMemberListCreateAPIEndpoint`` (v1) authorization.
Regression coverage for GHSA-w2vf-m9x9-mvmc (WEB-8075). The SAFE_METHODS branch
of ``ProjectMemberPermission`` only checked workspace membership, so a workspace
member who was NOT a member of a project could ``GET
/workspaces/<slug>/projects/<pid>/members/`` and read that project's full roster.
The fix scopes the SAFE_METHODS check to ``project_id=view.project_id`` so a
non-member is rejected with 403.
"""
from uuid import uuid4
import pytest
from rest_framework import status
from plane.db.models import Project, ProjectMember, User
def members_url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/members/"
@pytest.fixture
def attacker_membership(db, workspace, create_user):
"""Make the token holder (``create_user``) a member of an *unrelated* project.
The vulnerable SAFE_METHODS check was ``ProjectMember`` filtered by workspace
only (no project_id), so being a member of ANY project in the workspace let
the user read a foreign project's roster. Without this the request is denied
for the unrelated reason of having no project membership at all.
"""
other = Project.objects.create(
name="Attacker's Project",
identifier="ATK",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=other, member=create_user, workspace=workspace, role=20
)
return other
@pytest.fixture
def foreign_project(db, workspace):
"""A project owned by someone else; the token holder is NOT a member."""
unique_id = uuid4().hex[:8]
owner = User.objects.create(
email=f"owner-{unique_id}@plane.so",
username=f"owner_{unique_id}",
)
owner.set_password("test-password")
owner.save()
project = Project.objects.create(
name="Foreign Project",
identifier="FOR",
workspace=workspace,
created_by=owner,
)
ProjectMember.objects.create(
project=project, member=owner, workspace=workspace, role=20
)
return project
@pytest.mark.contract
class TestProjectMemberRosterScope:
"""The token holder (``create_user``) is a workspace member but not in the project."""
@pytest.mark.django_db
def test_non_project_member_cannot_list_roster(
self, api_key_client, workspace, attacker_membership, foreign_project
):
response = api_key_client.get(members_url(workspace.slug, foreign_project.id))
assert response.status_code == status.HTTP_403_FORBIDDEN, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_project_member_can_list_roster(self, api_key_client, workspace, create_user):
"""Positive control: an active project member still reads the roster."""
project = Project.objects.create(
name="Own Project",
identifier="OWN",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
response = api_key_client.get(members_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
returned = {str(row["id"]) for row in response.data}
assert str(create_user.id) in returned

View File

@@ -0,0 +1,261 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from plane.db.models import Project, ProjectMember, State, User, WorkspaceMember
@pytest.fixture
def other_workspace_member(db, workspace):
"""Create another user that is a member of the workspace, distinct from the creator."""
unique_id = uuid4().hex[:8]
other = User.objects.create(
email=f"other-{unique_id}@plane.so",
username=f"other_user_{unique_id}",
first_name="Other",
last_name="User",
)
other.set_password("test-password")
other.save()
WorkspaceMember.objects.create(workspace=workspace, member=other, role=20)
return other
@pytest.fixture
def outsider_user(db):
"""Create a user that is NOT a member of any workspace under test."""
unique_id = uuid4().hex[:8]
outsider = User.objects.create(
email=f"outsider-{unique_id}@plane.so",
username=f"outsider_{unique_id}",
first_name="Out",
last_name="Sider",
)
outsider.set_password("test-password")
outsider.save()
return outsider
@pytest.mark.contract
class TestProjectListCreateAPIEndpoint:
"""Contract tests for POST /api/v1/workspaces/{slug}/projects/."""
def get_url(self, workspace_slug):
return f"/api/v1/workspaces/{workspace_slug}/projects/"
@pytest.mark.django_db
def test_create_project_with_lead_as_creator(self, api_key_client, workspace, create_user):
"""Regression for the ghost-create bug.
When project_lead points to the creator's own user_id, the endpoint
must return 201 and create a fully-populated project (single
ProjectMember as admin, default workflow states).
Before the fix, the endpoint returned 400 "Please provide valid detail"
but had already persisted the Project row without states or members,
leaving an unusable orphan.
"""
url = self.get_url(workspace.slug)
payload = {
"name": "Self Lead Project",
"identifier": "SL",
"project_lead": str(create_user.id),
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
# Look up the project we just created instead of relying on
# ordering-sensitive Project.objects.first().
project = Project.objects.get(id=response.data["id"])
# Creator is registered as admin (single membership; lead == creator
# should not produce a duplicate row).
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
# Default workflow states must be created.
assert State.objects.filter(project=project).count() == 5
@pytest.mark.django_db
def test_create_project_with_lead_as_other_user(
self, api_key_client, workspace, create_user, other_workspace_member
):
"""When project_lead is a different workspace member, both creator
and lead become admins of the project."""
url = self.get_url(workspace.slug)
payload = {
"name": "Other Lead Project",
"identifier": "OL",
"project_lead": str(other_workspace_member.id),
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
project = Project.objects.get(id=response.data["id"])
# Both creator and other_workspace_member are admins.
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).exists()
assert ProjectMember.objects.filter(project=project, member=other_workspace_member, role=20).exists()
assert State.objects.filter(project=project).count() == 5
@pytest.mark.django_db
def test_create_project_without_lead(self, api_key_client, workspace, create_user):
"""Baseline regression: omitting project_lead must succeed and the
creator becomes the sole admin."""
url = self.get_url(workspace.slug)
payload = {
"name": "Basic Project",
"identifier": "BP",
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
project = Project.objects.get(id=response.data["id"])
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
assert State.objects.filter(project=project).count() == 5
@pytest.mark.django_db
def test_create_project_with_lead_not_in_workspace_returns_400(self, api_key_client, workspace, outsider_user):
"""When project_lead refers to a user that is NOT a member of the
target workspace, the endpoint must reject the request with a 400
carrying a field-shaped error and must not persist the Project."""
url = self.get_url(workspace.slug)
payload = {
"name": "Outsider Lead Project",
"identifier": "OUT",
"project_lead": str(outsider_user.id),
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST, f"Got {response.status_code}: {response.data!r}"
assert "project_lead" in response.data, (
f"Expected field-shaped error under 'project_lead', got {response.data!r}"
)
# No project should have been persisted.
assert Project.objects.count() == 0
@pytest.mark.django_db
def test_model_activity_not_called_on_rollback(self, api_key_client, workspace, create_user):
"""If anything inside the transaction.atomic() block raises, the
whole creation must roll back (no Project, no ProjectMember, no
State) and the deferred model_activity.delay() task must not fire,
because it is registered with transaction.on_commit().
Force the failure inside State.objects.bulk_create — past the point
where the original ghost-create bug would have committed a partial
Project — and verify the response is 500 with no side effects.
"""
url = self.get_url(workspace.slug)
payload = {
"name": "Rollback Probe",
"identifier": "RB",
"project_lead": str(create_user.id),
}
forced_error = RuntimeError("forced failure for rollback test")
with (
mock.patch(
"plane.api.views.project.State.objects.bulk_create",
side_effect=forced_error,
),
mock.patch("plane.api.views.project.model_activity") as mocked_activity,
):
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, (
f"Got {response.status_code}: {response.data!r}"
)
# Transaction must have rolled back: no Project, no ProjectMember,
# no State persisted.
assert Project.objects.count() == 0
assert ProjectMember.objects.count() == 0
assert State.objects.count() == 0
# And the deferred Celery task must not have been dispatched —
# transaction.on_commit() callbacks only fire on a successful commit.
mocked_activity.delay.assert_not_called()
@pytest.mark.django_db
def test_list_invalid_order_by_does_not_500(self, api_key_client, workspace, create_user):
"""Regression for GHSA-p885-6jpg-cr2p (DoS half).
An unknown ``order_by`` field used to reach Django's ``.order_by()``
raw and raise a ``FieldError`` → HTTP 500. After the fix the value is
sanitized to the safe default and the endpoint returns 200.
"""
Project.objects.create(
name="Ordered Project",
identifier="OP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(project=Project.objects.get(identifier="OP"), member=create_user, role=20)
url = self.get_url(workspace.slug)
response = api_key_client.get(url, {"order_by": "not_a_field"})
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
@pytest.mark.django_db
def test_list_relational_order_by_injection_does_not_500(self, api_key_client, workspace, create_user):
"""Regression for GHSA-p885-6jpg-cr2p (relational-traversal leak half).
Ordering by a related-table column (``created_by__password``) used to
reach ``.order_by()`` raw, forming a blind ordering oracle. After the
fix the value is not in ``PROJECT_ORDER_BY_ALLOWLIST`` and is replaced
with the safe default, so it can no longer influence SQL ordering.
(That the payload maps to the default is asserted deterministically in
tests/unit/utils/test_order_by_sanitize.py.)
"""
project = Project.objects.create(
name="Oracle Project",
identifier="OR",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(project=project, member=create_user, role=20)
url = self.get_url(workspace.slug)
response = api_key_client.get(url, {"order_by": "created_by__password"})
assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}"
@pytest.mark.django_db(transaction=True)
def test_response_still_201_when_broker_dispatch_fails(self, api_key_client, workspace, create_user):
"""If model_activity.delay raises *after* the atomic block has
committed (e.g., the Celery broker is down), the project, member
rows and states are already persisted — the response must remain
201 and the failure must be absorbed by Django's robust=True
on_commit handling, not surface as a 500.
Uses ``transaction=True`` so the surrounding test transaction is
actually committed and the ``on_commit`` callback fires (the
default ``django_db`` wrapper would suppress it via rollback)."""
url = self.get_url(workspace.slug)
payload = {
"name": "Broker Down",
"identifier": "BD",
"project_lead": str(create_user.id),
}
with mock.patch("plane.api.views.project.model_activity") as mocked_activity:
mocked_activity.delay.side_effect = RuntimeError("broker unavailable")
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
# Project and its scaffolding are persisted (commit happened
# before the on_commit callback fired).
project = Project.objects.get(id=response.data["id"])
assert ProjectMember.objects.filter(project=project).count() == 1
assert State.objects.filter(project=project).count() == 5
# The dispatch was attempted but its failure was swallowed by
# transaction.on_commit(robust=True).
mocked_activity.delay.assert_called_once()

View File

@@ -0,0 +1,103 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the projects-lite endpoint.
GET /api/v1/workspaces/<slug>/projects-lite/
"""
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import Project, ProjectMember
def _url(slug):
return f"/api/v1/workspaces/{slug}/projects-lite/"
@pytest.fixture
def project(db, workspace, create_user):
"""A non-archived project the requesting user is an active admin of."""
project = Project.objects.create(
name="Lite Project",
identifier="LP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.fixture
def archived_project(db, workspace, create_user):
"""An archived project the requesting user is an active admin of."""
project = Project.objects.create(
name="Archived Project",
identifier="ARCH",
workspace=workspace,
created_by=create_user,
archived_at=timezone.now(),
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.mark.contract
class TestProjectsLite:
@pytest.mark.django_db
def test_returns_paginated_results(self, api_key_client, workspace, project):
response = api_key_client.get(_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
ids = {str(item["id"]) for item in response.data["results"]}
assert str(project.id) in ids
@pytest.mark.django_db
def test_returns_only_lite_fields(self, api_key_client, workspace, project):
response = api_key_client.get(_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
item = response.data["results"][0]
# Trimmed shape — archived_at present, heavy computed fields absent.
for key in ("id", "identifier", "name", "cover_image_url", "archived_at"):
assert key in item
assert "total_members" not in item
@pytest.mark.django_db
def test_archived_excluded_by_default(self, api_key_client, workspace, project, archived_project):
response = api_key_client.get(_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(item["id"]) for item in response.data["results"]}
assert str(project.id) in ids
assert str(archived_project.id) not in ids
@pytest.mark.django_db
def test_include_archived_returns_all(self, api_key_client, workspace, project, archived_project):
response = api_key_client.get(_url(workspace.slug), {"include_archived": "true"})
assert response.status_code == status.HTTP_200_OK
ids = {str(item["id"]) for item in response.data["results"]}
assert str(project.id) in ids
assert str(archived_project.id) in ids
@pytest.mark.django_db
def test_unknown_workspace_is_rejected(self, api_key_client, project):
response = api_key_client.get(_url("does-not-exist"))
assert response.status_code in (
status.HTTP_400_BAD_REQUEST,
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND,
)

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,419 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from datetime import timedelta
from uuid import uuid4
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from plane.db.models import APIToken, User
@pytest.mark.contract
class TestApiTokenEndpoint:
"""Test cases for ApiTokenEndpoint"""
# POST /user/api-tokens/ tests
@pytest.mark.django_db
def test_create_api_token_success(self, session_client, create_user, api_token_data):
"""Test successful API token creation"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert response.data["label"] == api_token_data["label"]
assert response.data["description"] == api_token_data["description"]
assert response.data["user_type"] == 0 # Human user
# Verify token was created in database
token = APIToken.objects.get(pk=response.data["id"])
assert token.user == create_user
assert token.label == api_token_data["label"]
@pytest.mark.django_db
def test_create_api_token_for_bot_user(self, session_client, create_bot_user, api_token_data):
"""Test API token creation for bot user"""
# Arrange
session_client.force_authenticate(user=create_bot_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert response.data["user_type"] == 1 # Bot user
@pytest.mark.django_db
def test_create_api_token_minimal_data(self, session_client, create_user):
"""Test API token creation with minimal data"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, {}, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert len(response.data["label"]) == 32 # UUID hex length
assert response.data["description"] == ""
@pytest.mark.django_db
def test_create_api_token_with_expiry(self, session_client, create_user):
"""Test API token creation with expiry date"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
future_date = timezone.now() + timedelta(days=30)
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
# Act
response = session_client.post(url, data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
# Verify expiry date was set
token = APIToken.objects.get(pk=response.data["id"])
assert token.expired_at is not None
@pytest.mark.django_db
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
"""Test API token creation without authentication"""
# Arrange
url = reverse("api-tokens")
# Act
response = api_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# GET /user/api-tokens/ tests
@pytest.mark.django_db
def test_get_all_api_tokens(self, session_client, create_user):
"""Test retrieving all API tokens for user"""
# Arrange
session_client.force_authenticate(user=create_user)
# Create multiple tokens
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
# Create a service token (should be excluded)
APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 2 # Only non-service tokens
assert all(token["is_service"] is False for token in response.data)
@pytest.mark.django_db
def test_get_empty_api_tokens_list(self, session_client, create_user):
"""Test retrieving API tokens when none exist"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data == []
# GET /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_get_specific_api_token(self, session_client, create_user, create_api_token_for_user):
"""Test retrieving a specific API token"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
assert response.data["label"] == create_api_token_for_user.label
assert "token" not in response.data # Token should not be visible in read serializer
@pytest.mark.django_db
def test_get_nonexistent_api_token(self, session_client, create_user):
"""Test retrieving a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_get_other_users_api_token(self, session_client, create_user, db):
"""Test retrieving another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"other-{unique_id}@plane.so"
unique_username = f"other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# DELETE /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_delete_api_token_success(self, session_client, create_user, create_api_token_for_user):
"""Test successful API token deletion"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
@pytest.mark.django_db
def test_delete_nonexistent_api_token(self, session_client, create_user):
"""Test deleting a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_delete_other_users_api_token(self, session_client, create_user, db):
"""Test deleting another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"delete-other-{unique_id}@plane.so"
unique_username = f"delete_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=other_token.pk).exists()
@pytest.mark.django_db
def test_delete_service_api_token_forbidden(self, session_client, create_user):
"""Test deleting a service API token (should fail)"""
# Arrange
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=service_token.pk).exists()
# PATCH /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_patch_api_token_success(self, session_client, create_user, create_api_token_for_user):
"""Test successful API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
update_data = {
"label": "Updated Token Label",
"description": "Updated description",
}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == update_data["description"]
# Verify database was updated
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.label == update_data["label"]
assert create_api_token_for_user.description == update_data["description"]
@pytest.mark.django_db
def test_patch_api_token_partial_update(self, session_client, create_user, create_api_token_for_user):
"""Test partial API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
original_description = create_api_token_for_user.description
update_data = {"label": "Only Label Updated"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == original_description
@pytest.mark.django_db
def test_patch_nonexistent_api_token(self, session_client, create_user):
"""Test updating a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
update_data = {"label": "New Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_patch_other_users_api_token(self, session_client, create_user, db):
"""Test updating another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"patch-other-{unique_id}@plane.so"
unique_username = f"patch_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
update_data = {"label": "Hacked Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token was not updated
other_token.refresh_from_db()
assert other_token.label == "Other Token"
@pytest.mark.django_db
def test_patch_cannot_modify_token(self, session_client, create_user, create_api_token_for_user):
"""Test that token value cannot be modified via PATCH"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
original_token = create_api_token_for_user.token
update_data = {"token": "plane_api_malicious_token_value"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.token == original_token
@pytest.mark.django_db
def test_patch_cannot_modify_user_type(self, session_client, create_user, create_api_token_for_user):
"""Test that user_type cannot be modified via PATCH"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
update_data = {"user_type": 1}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.user_type == 0
@pytest.mark.django_db
def test_patch_cannot_modify_allowed_rate_limit(self, session_client, create_user, create_api_token_for_user):
"""Test that allowed_rate_limit cannot be modified via PATCH"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
original_rate_limit = create_api_token_for_user.allowed_rate_limit
update_data = {"allowed_rate_limit": "100000/min"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.allowed_rate_limit == original_rate_limit
@pytest.mark.django_db
def test_patch_cannot_modify_service_token(self, session_client, create_user):
"""Test that service tokens cannot be modified through user token endpoint"""
# Arrange
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
update_data = {"label": "Hacked Service Token"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
service_token.refresh_from_db()
assert service_token.label == "Service Token"
# Authentication tests
@pytest.mark.django_db
def test_all_endpoints_require_authentication(self, api_client):
"""Test that all endpoints require authentication"""
# Arrange
endpoints = [
(reverse("api-tokens"), "get"),
(reverse("api-tokens"), "post"),
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "delete"),
]
# Act & Assert
for url, method in endpoints:
response = getattr(api_client, method)(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View File

@@ -0,0 +1,847 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
import uuid
import pytest
from django.core.cache import cache
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from django.test import Client
from django.core.exceptions import ValidationError
from unittest.mock import patch
from plane.authentication.provider.credentials.magic_code import MagicCodeProvider
from plane.authentication.rate_limit import AuthenticationThrottle
from plane.db.models import User
from plane.settings.redis import redis_instance
from plane.license.models import Instance
def _clear_auth_throttle_keys():
"""Delete only the AuthenticationThrottle history keys from the shared cache.
DRF's SimpleRateThrottle stores request history under
``throttle_<scope>_<ident>`` keys, so scoping the pattern to
``throttle_authentication_`` removes just this throttle's entries instead
of wiping unrelated cache state.
"""
cache.delete_pattern("throttle_authentication_*")
@pytest.fixture(autouse=True)
def _reset_auth_throttle_cache():
"""Clear the auth throttle state around every test in this module.
The auth endpoints apply a per-IP throttle (AuthenticationThrottle) whose
request history lives in the Django cache. Because the test session reuses a
single cache, that count leaks across tests and trips RATE_LIMIT_EXCEEDED in
classes that don't reset it. This mirrors the per-class ``_clear_state``
fixtures already used by the throttle / verify-attempt classes, applied
module-wide so every auth test starts from a clean throttle state.
"""
_clear_auth_throttle_keys()
yield
_clear_auth_throttle_keys()
@pytest.fixture
def setup_instance(db):
"""Create and configure an instance for authentication tests"""
instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id
# Create or update instance with all required fields
instance, _ = Instance.objects.update_or_create(
id=instance_id,
defaults={
"instance_name": "Test Instance",
"instance_id": str(uuid.uuid4()),
"current_version": "1.0.0",
"domain": "http://localhost:8000",
"last_checked_at": timezone.now(),
"is_setup_done": True,
},
)
return instance
@pytest.fixture
def django_client():
"""Return a Django test client with User-Agent header for handling redirects"""
client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1")
return client
@pytest.fixture(autouse=True)
def _reset_authentication_throttle():
"""
Reset the shared AuthenticationThrottle bucket around every test in this module.
All auth endpoints share one per-IP throttle bucket (scope "authentication")
and the test client always presents the same IP, so without clearing the cache
between tests the request count accumulates across tests and later ones trip
RATE_LIMIT_EXCEEDED. Clearing here keeps each test's rate-limit budget isolated.
"""
cache.clear()
yield
cache.clear()
@pytest.mark.contract
class TestMagicLinkGenerate:
"""Test magic link generation functionality"""
@pytest.fixture
def setup_user(self, db):
"""Create a test user for magic link tests"""
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
return user
@pytest.mark.django_db
def test_without_data(self, api_client, setup_user, setup_instance):
"""Test magic link generation with empty data"""
url = reverse("magic-generate")
try:
response = api_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
except ValidationError:
# If a ValidationError is raised directly, that's also acceptable
# as it indicates the empty email was rejected
assert True
@pytest.mark.django_db
def test_email_validity(self, api_client, setup_user, setup_instance):
"""Test magic link generation with invalid email format"""
url = reverse("magic-generate")
try:
response = api_client.post(url, {"email": "useremail.com"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "error_code" in response.data # Check for error code in response
except ValidationError:
# If a ValidationError is raised directly, that's also acceptable
# as it indicates the invalid email was rejected
assert True
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance):
"""Test successful magic link generation"""
url = reverse("magic-generate")
ri = redis_instance()
ri.delete("magic_user@plane.so")
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
assert response.status_code == status.HTTP_200_OK
assert "key" in response.data # Check for key in response
# Verify the mock was called with the expected arguments
mock_magic_link.assert_called_once()
args = mock_magic_link.call_args[0]
assert args[0] == "user@plane.so" # First arg should be the email
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance):
"""Test exceeding maximum magic link generation attempts"""
url = reverse("magic-generate")
ri = redis_instance()
ri.delete("magic_user@plane.so")
for _ in range(4):
api_client.post(url, {"email": "user@plane.so"}, format="json")
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "error_code" in response.data # Check for error code in response
@pytest.mark.contract
class TestSignInEndpoint:
"""Test sign-in functionality"""
@pytest.fixture
def setup_user(self, db):
"""Create a test user for authentication tests"""
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
return user
@pytest.mark.django_db
def test_without_data(self, django_client, setup_user, setup_instance):
"""Test sign-in with empty data"""
url = reverse("sign-in")
response = django_client.post(url, {}, follow=True)
# Check redirect contains error code
assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_email_validity(self, django_client, setup_user, setup_instance):
"""Test sign-in with invalid email format"""
url = reverse("sign-in")
response = django_client.post(url, {"email": "useremail.com", "password": "user@123"}, follow=True)
# Check redirect contains error code
assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_user_exists(self, django_client, setup_user, setup_instance):
"""Test sign-in with non-existent user"""
url = reverse("sign-in")
response = django_client.post(url, {"email": "user@email.so", "password": "user123"}, follow=True)
# Check redirect contains error code
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_password_validity(self, django_client, setup_user, setup_instance):
"""Test sign-in with incorrect password"""
url = reverse("sign-in")
response = django_client.post(url, {"email": "user@plane.so", "password": "user123"}, follow=True)
# Check for the specific authentication error in the URL
redirect_urls = [url for url, _ in response.redirect_chain]
redirect_contents = " ".join(redirect_urls)
# The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN
assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents
@pytest.mark.django_db
def test_user_login(self, django_client, setup_user, setup_instance):
"""Test successful sign-in"""
url = reverse("sign-in")
# First make the request without following redirects
response = django_client.post(url, {"email": "user@plane.so", "password": "user@123"}, follow=False)
# Check that the initial response is a redirect (302) without error code
assert response.status_code == 302
assert "error_code" not in response.url
# Now follow just the first redirect to avoid 404s
response = django_client.get(response.url, follow=False)
# The user should be authenticated regardless of the final page
assert "_auth_user_id" in django_client.session
@pytest.mark.django_db
def test_next_path_redirection(self, django_client, setup_user, setup_instance):
"""Test sign-in with next_path parameter"""
url = reverse("sign-in")
next_path = "workspaces"
# First make the request without following redirects
response = django_client.post(
url,
{"email": "user@plane.so", "password": "user@123", "next_path": next_path},
follow=False,
)
# Check that the initial response is a redirect (302) without error code
assert response.status_code == 302
assert "error_code" not in response.url
# In a real browser, the next_path would be used to build the absolute URL
# Since we're just testing the authentication logic, we won't check for the exact URL structure
# Instead, just verify that we're authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.contract
class TestMagicSignIn:
"""Test magic link sign-in functionality"""
@pytest.fixture
def setup_user(self, db):
"""Create a test user for magic sign-in tests"""
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
return user
@pytest.mark.django_db
def test_without_data(self, django_client, setup_user, setup_instance):
"""Test magic link sign-in with empty data"""
url = reverse("magic-sign-in")
response = django_client.post(url, {}, follow=True)
# Check redirect contains error code
assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance):
"""Test magic link sign-in with expired/invalid link"""
ri = redis_instance()
ri.delete("magic_user@plane.so")
url = reverse("magic-sign-in")
response = django_client.post(url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False)
# Check that we get a redirect
assert response.status_code == 302
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist)
# or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match)
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url
@pytest.mark.django_db
def test_user_does_not_exist(self, django_client, setup_instance):
"""Test magic sign-in with non-existent user"""
url = reverse("magic-sign-in")
response = django_client.post(
url,
{"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"},
follow=True,
)
# Check redirect contains error code
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
"""Test successful magic link sign-in process"""
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get("magic_user@plane.so"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
url = reverse("magic-sign-in")
response = django_client.post(url, {"email": "user@plane.so", "code": token}, follow=False)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# The user should now be authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
"""Test magic sign-in with next_path parameter"""
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get("magic_user@plane.so"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects.
# next_path must start with "/" per validate_next_path (otherwise it's discarded).
url = reverse("magic-sign-in")
next_path = "/workspaces"
response = django_client.post(
url,
{"email": "user@plane.so", "code": token, "next_path": next_path},
follow=False,
)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# Check that the redirect URL contains the next_path (URL-encoded, leading slash → %2F)
assert "workspaces" in response.url
# The user should now be authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.contract
class TestMagicSignUp:
"""Test magic link sign-up functionality"""
@pytest.mark.django_db
def test_without_data(self, django_client, setup_instance):
"""Test magic link sign-up with empty data"""
url = reverse("magic-sign-up")
response = django_client.post(url, {}, follow=True)
# Check redirect contains error code
assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_user_already_exists(self, django_client, db, setup_instance):
"""Test magic sign-up with existing user"""
# Create a user that already exists
User.objects.create(email="existing@plane.so")
url = reverse("magic-sign-up")
response = django_client.post(url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True)
# Check redirect contains error code
assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_expired_invalid_magic_link(self, django_client, setup_instance):
"""Test magic link sign-up with expired/invalid link"""
url = reverse("magic-sign-up")
response = django_client.post(url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False)
# Check that we get a redirect
assert response.status_code == 302
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist)
# or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match)
assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance):
"""Test successful magic link sign-up process"""
email = "newuser@plane.so"
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": email}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get(f"magic_{email}"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
url = reverse("magic-sign-up")
response = django_client.post(url, {"email": email, "code": token}, follow=False)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# Check if user was created
assert User.objects.filter(email=email).exists()
# Check if user is authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance):
"""Test magic sign-up with next_path parameter"""
email = "newuser2@plane.so"
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": email}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get(f"magic_{email}"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
url = reverse("magic-sign-up")
next_path = "onboarding"
response = django_client.post(url, {"email": email, "code": token, "next_path": next_path}, follow=False)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# In a real browser, the next_path would be used to build the absolute URL
# Since we're just testing the authentication logic, we won't check for the exact URL structure
# Check if user was created
assert User.objects.filter(email=email).exists()
# Check if user is authenticated
assert "_auth_user_id" in django_client.session
def _generate_magic_token(api_client, email):
"""Hit /magic-generate/ for `email` and return the token that landed in Redis."""
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": email}, format="json")
assert response.status_code == status.HTTP_200_OK
ri = redis_instance()
return json.loads(ri.get(f"magic_{email}"))["token"]
@pytest.mark.contract
class TestMagicSignInVerifyAttempts:
"""Per-token wrong-code attempt counter and exhaustion behavior (GHSA-9pvm-fcf6-9234)."""
EMAIL = "verify-attempts@plane.so"
@pytest.fixture
def setup_user(self, db):
user = User.objects.create(email=self.EMAIL)
user.set_password("user@123")
user.save()
return user
@pytest.fixture(autouse=True)
def _clear_state(self):
"""Reset throttle cache and magic-link redis state between tests in this class."""
cache.clear()
ri = redis_instance()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
yield
cache.clear()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_exhausted_after_max_wrong_attempts(
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
):
"""
After MAX_VERIFY_ATTEMPTS wrong codes the next verify must redirect with
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN and both Redis keys must be gone.
With MAX_VERIFY_ATTEMPTS=5 the 5th wrong attempt itself triggers exhaustion
(4 INVALID + 1 EXHAUSTED), matching the >= check in set_user_data.
"""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-in")
ri = redis_instance()
# First (MAX-1) wrong attempts: each redirects with INVALID_MAGIC_CODE_SIGN_IN.
for i in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1):
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert response.status_code == 302, f"attempt {i+1} unexpected status"
assert "INVALID_MAGIC_CODE_SIGN_IN" in response.url, f"attempt {i+1} did not return INVALID"
# Token and counter both still live, with counter at MAX-1.
assert ri.exists(f"magic_{self.EMAIL}")
assert int(ri.get(f"magic_{self.EMAIL}:verify_attempts")) == MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1
# The MAX-th wrong attempt is the exhausting one.
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert response.status_code == 302
assert "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN" in response.url
# Both the token and the counter must be deleted.
assert not ri.exists(f"magic_{self.EMAIL}")
assert not ri.exists(f"magic_{self.EMAIL}:verify_attempts")
# Follow-up verify now sees the key as missing and reports EXPIRED.
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert response.status_code == 302
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_counter_increments_on_each_wrong_attempt(
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
):
"""The verify_attempts counter increments by exactly one per wrong-code POST."""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-in")
ri = redis_instance()
counter_key = f"magic_{self.EMAIL}:verify_attempts"
# Before any wrong attempt the counter does not exist (Lua INCR creates it).
assert not ri.exists(counter_key)
for expected in range(1, MagicCodeProvider.MAX_VERIFY_ATTEMPTS):
django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert int(ri.get(counter_key)) == expected, f"counter mismatch after {expected} attempts"
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_counter_resets_on_token_regeneration(
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
):
"""
Regenerating the magic-link must reset the verify-attempt counter so the
user isn't pre-locked-out by a previous session's wrong attempts.
"""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-in")
ri = redis_instance()
counter_key = f"magic_{self.EMAIL}:verify_attempts"
for _ in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 2):
django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert int(ri.get(counter_key)) == MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 2
# Regenerate the magic-link — the counter should be cleared.
_generate_magic_token(api_client, self.EMAIL)
assert not ri.exists(counter_key)
# Fresh wrong attempt now produces INVALID (not EXHAUSTED) and counter starts at 1.
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert "INVALID_MAGIC_CODE_SIGN_IN" in response.url
assert int(ri.get(counter_key)) == 1
@pytest.mark.contract
class TestMagicSignUpVerifyAttempts:
"""Sign-up flow gets the same per-token attempt cap (no existing User row)."""
EMAIL = "signup-verify-attempts@plane.so"
@pytest.fixture(autouse=True)
def _clear_state(self):
cache.clear()
ri = redis_instance()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
yield
cache.clear()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_signup_exhausted_after_max_wrong_attempts(
self, mock_magic_link, django_client, api_client, setup_instance
):
"""The MAX-th wrong code on the sign-up endpoint returns the SIGN_UP variant of EXHAUSTED."""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-up")
ri = redis_instance()
for _ in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1):
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert "INVALID_MAGIC_CODE_SIGN_UP" in response.url
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP" in response.url
assert not ri.exists(f"magic_{self.EMAIL}")
assert not ri.exists(f"magic_{self.EMAIL}:verify_attempts")
@pytest.mark.contract
class TestAuthenticationThrottle:
"""Per-IP throttle on the redirect-flow magic-link and password endpoints."""
@pytest.fixture(autouse=True)
def _clear_state(self):
cache.clear()
yield
cache.clear()
@pytest.mark.django_db
def test_magic_sign_in_throttled(self, django_client, setup_instance):
"""Posting past the configured rate from one IP returns RATE_LIMIT_EXCEEDED."""
url = reverse("magic-sign-in")
# Drop the rate so the test doesn't have to fire 10+ requests.
with patch.object(AuthenticationThrottle, "rate", "2/minute"):
for _ in range(2):
response = django_client.post(url, {"email": "throttle@plane.so", "code": "000000"}, follow=False)
assert response.status_code == 302
assert "RATE_LIMIT_EXCEEDED" not in response.url
# The 3rd request from the same IP within the window trips the throttle.
response = django_client.post(url, {"email": "throttle@plane.so", "code": "000000"}, follow=False)
assert response.status_code == 302
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.django_db
def test_magic_sign_up_throttled(self, django_client, setup_instance):
"""The sign-up sibling shares the same scope and trips on the same per-IP budget."""
url = reverse("magic-sign-up")
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" not in response.url
response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.django_db
def test_password_sign_in_throttled(self, django_client, setup_instance):
"""The password sign-in endpoint is throttled per IP on the same scope."""
url = reverse("sign-in")
with patch.object(AuthenticationThrottle, "rate", "2/minute"):
for _ in range(2):
response = django_client.post(
url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False
)
assert response.status_code == 302
assert "RATE_LIMIT_EXCEEDED" not in response.url
# The 3rd request from the same IP within the window trips the throttle.
response = django_client.post(url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False)
assert response.status_code == 302
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.django_db
def test_password_sign_up_throttled(self, django_client, setup_instance):
"""The password sign-up endpoint trips on the same per-IP budget."""
url = reverse("sign-up")
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" not in response.url
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.django_db
def test_space_password_sign_in_throttled(self, django_client, setup_instance):
"""The spaces password sign-in endpoint is throttled per IP."""
url = reverse("space-sign-in")
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
response = django_client.post(url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" not in response.url
response = django_client.post(url, {"email": "throttle@plane.so", "password": "secret123"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.django_db
def test_space_password_sign_up_throttled(self, django_client, setup_instance):
"""The spaces password sign-up endpoint trips on the same per-IP budget."""
url = reverse("space-sign-up")
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" not in response.url
response = django_client.post(url, {"email": "throttle-up@plane.so", "password": "secret123"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.contract
class TestBotUserLoginBlocked:
"""Bot service accounts (is_bot=True) must never authenticate through the
interactive login flow.
Bots are internal identities (e.g. the WORKSPACE_SEED bot) that act only via
API tokens. Every interactive provider funnels through
Adapter.complete_login_or_signup(), which rejects bots with
BOT_USER_LOGIN_FORBIDDEN (5017). These are regression guards for that block.
"""
BOT_EMAIL = "bot-login@plane.so"
HUMAN_EMAIL = "human-login@plane.so"
PASSWORD = "Str0ng-Pass!42"
@pytest.fixture(autouse=True)
def _clear_state(self):
"""Reset throttle cache and the bot's magic-link redis state around each test."""
cache.clear()
ri = redis_instance()
ri.delete(f"magic_{self.BOT_EMAIL}")
ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts")
yield
cache.clear()
ri.delete(f"magic_{self.BOT_EMAIL}")
ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts")
@pytest.fixture
def bot_user(self, db):
"""An active bot account with a known password so the credential check
passes and execution reaches the login chokepoint."""
user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True)
user.set_password(self.PASSWORD)
user.save()
return user
@pytest.fixture
def human_user(self, db):
"""A normal (non-bot) account, identical apart from is_bot, as a control."""
user = User.objects.create(email=self.HUMAN_EMAIL, is_active=True)
user.set_password(self.PASSWORD)
user.save()
return user
@pytest.mark.django_db
def test_bot_password_sign_in_blocked(self, django_client, bot_user, setup_instance):
"""Password sign-in with a bot's *correct* credentials is still rejected:
the block happens after credential verification, so no session is created."""
url = reverse("sign-in")
response = django_client.post(
url, {"email": self.BOT_EMAIL, "password": self.PASSWORD}, follow=False
)
assert response.status_code == 302
assert "BOT_USER_LOGIN_FORBIDDEN" in response.url
# The block must prevent authentication.
assert "_auth_user_id" not in django_client.session
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_bot_magic_sign_in_blocked(
self, mock_magic_link, django_client, api_client, bot_user, setup_instance
):
"""The same block applies via a second provider (magic code), proving the
guard sits at the shared chokepoint rather than in one provider."""
token = _generate_magic_token(api_client, self.BOT_EMAIL)
url = reverse("magic-sign-in")
response = django_client.post(url, {"email": self.BOT_EMAIL, "code": token}, follow=False)
assert response.status_code == 302
assert "BOT_USER_LOGIN_FORBIDDEN" in response.url
assert "_auth_user_id" not in django_client.session
@pytest.mark.django_db
def test_human_password_sign_in_allowed(self, django_client, human_user, setup_instance):
"""Control: a normal user with the identical setup still signs in — the
guard is scoped strictly to is_bot and does not regress human logins."""
url = reverse("sign-in")
response = django_client.post(
url, {"email": self.HUMAN_EMAIL, "password": self.PASSWORD}, follow=False
)
assert response.status_code == 302
assert "BOT_USER_LOGIN_FORBIDDEN" not in response.url
assert "error_code" not in response.url
assert "_auth_user_id" in django_client.session
@pytest.mark.contract
class TestBotUserAdminSignInBlocked:
"""A bot must not sign in to the instance-admin console either.
InstanceAdminSignInEndpoint mints its own session via user_login() outside
Adapter.complete_login_or_signup(), so it carries an independent is_bot
guard that rejects bots with ADMIN_AUTHENTICATION_FAILED before the admin
membership check. (Uses the literal path because license/urls.py reuses the
name "instance-admin-sign-in" for both sign-in and sign-up.)
"""
ADMIN_SIGN_IN_PATH = "/api/instances/admins/sign-in/"
BOT_EMAIL = "admin-bot@plane.so"
PASSWORD = "Str0ng-Pass!42"
@pytest.fixture(autouse=True)
def _clear_state(self):
cache.clear()
yield
cache.clear()
@pytest.fixture
def bot_user(self, db):
user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True)
user.set_password(self.PASSWORD)
user.save()
return user
@pytest.mark.django_db
def test_bot_admin_sign_in_blocked(self, django_client, bot_user, setup_instance):
"""A bot is rejected at the admin sign-in endpoint and no session is created,
even though it is active and the password is correct."""
response = django_client.post(
self.ADMIN_SIGN_IN_PATH,
{"email": self.BOT_EMAIL, "password": self.PASSWORD},
follow=False,
)
assert response.status_code == 302
assert "ADMIN_AUTHENTICATION_FAILED" in response.url
assert "_auth_user_id" not in django_client.session

View File

@@ -0,0 +1,213 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Regression tests for the ``avatar_url`` annotation used by the assignee
distribution / analytics querysets.
Those querysets build the avatar URL with
``Concat(Value("/api/assets/v2/static/"), <user>__avatar_asset, Value("/"))``.
On Django 5.x ``ConcatPair.as_postgresql`` resolves the output field of each
argument, so concatenating a ``CharField`` with the raw ``UUIDField`` column
raises ``FieldError: Expression contains mixed types``. Every endpoint below
returned a 500 until the UUID column was explicitly cast to text.
"""
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import (
Cycle,
CycleIssue,
FileAsset,
Issue,
IssueAssignee,
Module,
ModuleIssue,
Project,
ProjectMember,
State,
)
@pytest.fixture
def user_with_avatar_asset(db, workspace, create_user):
"""A user whose avatar comes from a FileAsset, i.e. the branch of the
``Case`` expression that concatenates the asset UUID into a URL."""
asset = FileAsset.objects.create(
workspace=workspace,
asset=f"{workspace.id}/avatar.png",
size=1024,
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
user=create_user,
is_uploaded=True,
)
create_user.avatar_asset = asset
create_user.save()
return create_user
@pytest.fixture
def project(db, workspace, user_with_avatar_asset):
project = Project.objects.create(
name="Avatar URL Project",
identifier="AVU",
workspace=workspace,
created_by=user_with_avatar_asset,
module_view=True,
cycle_view=True,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=user_with_avatar_asset,
role=20,
is_active=True,
)
return project
@pytest.fixture
def assigned_issue(db, project, user_with_avatar_asset):
"""A work item assigned to the avatar-bearing user, so the distribution
querysets have at least one row to annotate."""
state = State.objects.create(name="Todo", group="unstarted", project=project, workspace=project.workspace)
issue = Issue.objects.create(
name="Avatar URL Issue",
project=project,
workspace=project.workspace,
state=state,
created_by=user_with_avatar_asset,
)
IssueAssignee.objects.create(
issue=issue,
assignee=user_with_avatar_asset,
project=project,
workspace=project.workspace,
)
return issue
@pytest.fixture
def module(db, project, assigned_issue):
module = Module.objects.create(name="Avatar URL Module", project=project, workspace=project.workspace)
ModuleIssue.objects.create(issue=assigned_issue, module=module, project=project, workspace=project.workspace)
return module
@pytest.fixture
def archived_module(db, project, assigned_issue):
module = Module.objects.create(
name="Archived Avatar URL Module",
project=project,
workspace=project.workspace,
archived_at=timezone.now(),
)
ModuleIssue.objects.create(issue=assigned_issue, module=module, project=project, workspace=project.workspace)
return module
@pytest.fixture
def cycle(db, project, assigned_issue, user_with_avatar_asset):
cycle = Cycle.objects.create(
name="Avatar URL Cycle",
project=project,
workspace=project.workspace,
owned_by=user_with_avatar_asset,
start_date=timezone.now(),
end_date=timezone.now() + timezone.timedelta(days=7),
)
CycleIssue.objects.create(issue=assigned_issue, cycle=cycle, project=project, workspace=project.workspace)
return cycle
@pytest.fixture
def archived_cycle(db, project, user_with_avatar_asset):
return Cycle.objects.create(
name="Archived Avatar URL Cycle",
project=project,
workspace=project.workspace,
owned_by=user_with_avatar_asset,
archived_at=timezone.now(),
)
@pytest.mark.contract
class TestAvatarUrlAnnotation:
@pytest.mark.django_db
def test_module_retrieve_builds_avatar_url_from_asset(
self, session_client, workspace, project, module, user_with_avatar_asset
):
response = session_client.get(f"/api/workspaces/{workspace.slug}/projects/{project.id}/modules/{module.id}/")
assert response.status_code == status.HTTP_200_OK
assignees = list(response.data["distribution"]["assignees"])
assert [a["avatar_url"] for a in assignees] == [
f"/api/assets/v2/static/{user_with_avatar_asset.avatar_asset_id}/"
]
@pytest.mark.django_db
def test_archived_module_retrieve(self, session_client, workspace, project, archived_module):
response = session_client.get(
f"/api/workspaces/{workspace.slug}/projects/{project.id}/archived-modules/{archived_module.id}/"
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_archived_cycle_retrieve(self, session_client, workspace, project, archived_cycle):
response = session_client.get(
f"/api/workspaces/{workspace.slug}/projects/{project.id}/archived-cycles/{archived_cycle.id}/"
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_cycle_analytics(self, session_client, workspace, project, cycle):
response = session_client.get(
f"/api/workspaces/{workspace.slug}/projects/{project.id}/cycles/{cycle.id}/analytics/?type=issues"
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_workspace_analytics(self, session_client, workspace, project, assigned_issue):
response = session_client.get(
f"/api/workspaces/{workspace.slug}/analytics/?x_axis=assignees__id&y_axis=issue_count"
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_default_analytics(self, session_client, workspace, project, assigned_issue):
response = session_client.get(f"/api/workspaces/{workspace.slug}/default-analytics/")
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_project_advance_analytics_stats(self, session_client, workspace, project, assigned_issue):
response = session_client.get(
f"/api/workspaces/{workspace.slug}/projects/{project.id}/advance-analytics-stats/?type=work-items"
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_entity_search_user_mention(self, session_client, workspace, project):
response = session_client.get(
f"/api/workspaces/{workspace.slug}/entity-search/"
f"?query_type=user_mention&query=Test&project_id={project.id}"
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_cycle_transfer_issues(self, session_client, workspace, project, cycle, user_with_avatar_asset):
new_cycle = Cycle.objects.create(
name="Transfer Target Cycle",
project=project,
workspace=project.workspace,
owned_by=user_with_avatar_asset,
start_date=timezone.now() + timezone.timedelta(days=8),
end_date=timezone.now() + timezone.timedelta(days=14),
)
response = session_client.post(
f"/api/workspaces/{workspace.slug}/projects/{project.id}/cycles/{cycle.id}/transfer-issues/",
{"new_cycle_id": str(new_cycle.id)},
format="json",
)
assert response.status_code == status.HTTP_200_OK

View File

@@ -0,0 +1,176 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Regression test for GHSA-4w5x-wc9w-f47x.
CycleIssueViewSet.create reassigned any CycleIssue row matched by issue_id to
the caller's cycle without scoping the lookup to the caller's
workspace/project. An ADMIN/MEMBER of their own project could therefore pass a
work-item UUID from a *different* tenant and silently evict the victim's work
item from the victim's cycle (cross-tenant write / BOLA).
"""
from uuid import uuid4
import pytest
from rest_framework import status
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
Project,
ProjectMember,
State,
User,
Workspace,
WorkspaceMember,
)
@pytest.fixture
def attacker_project(db, workspace, create_user):
"""Project + cycle in the attacker's own workspace; attacker is admin."""
project = Project.objects.create(
name="Attacker Project",
identifier="ATK",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
return project
@pytest.fixture
def attacker_cycle(db, workspace, attacker_project, create_user):
return Cycle.objects.create(
name="Attacker Cycle",
project=attacker_project,
workspace=workspace,
owned_by=create_user,
)
@pytest.fixture
def victim_tenant(db):
"""A completely separate workspace/project/cycle owning a work item that is
already assigned to the victim's own cycle."""
uid = uuid4().hex[:8]
victim_user = User.objects.create(
email=f"victim-{uid}@plane.so",
username=f"victim_{uid}",
first_name="Victim",
last_name="User",
)
victim_ws = Workspace.objects.create(name="Victim WS", owner=victim_user, slug=f"victim-{uid}")
WorkspaceMember.objects.create(workspace=victim_ws, member=victim_user, role=20)
victim_project = Project.objects.create(
name="Victim Project",
identifier="VIC",
workspace=victim_ws,
created_by=victim_user,
)
ProjectMember.objects.create(project=victim_project, member=victim_user, role=20, is_active=True)
state = State.objects.create(
name="Todo", project=victim_project, workspace=victim_ws, group="backlog", default=True
)
victim_issue = Issue.objects.create(
name="Victim Issue",
workspace=victim_ws,
project=victim_project,
state=state,
created_by=victim_user,
)
victim_cycle = Cycle.objects.create(
name="Victim Cycle", project=victim_project, workspace=victim_ws, owned_by=victim_user
)
cycle_issue = CycleIssue.objects.create(
issue=victim_issue,
cycle=victim_cycle,
project=victim_project,
workspace=victim_ws,
created_by=victim_user,
)
return {
"issue": victim_issue,
"cycle": victim_cycle,
"cycle_issue": cycle_issue,
}
@pytest.mark.contract
class TestCycleIssueCrossTenantBOLA:
def get_url(self, workspace_slug, project_id, cycle_id):
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues/"
@pytest.mark.django_db
def test_foreign_tenant_cycle_issue_not_reassigned(
self, session_client, workspace, attacker_project, attacker_cycle, victim_tenant
):
"""The attacker adds a foreign-tenant work-item UUID to their own cycle.
Before the fix the victim's CycleIssue row was reassigned to the
attacker's cycle (cycle_id flipped). After the fix the foreign row is
excluded from the lookup, so it stays in the victim's cycle.
"""
victim_issue = victim_tenant["issue"]
victim_cycle = victim_tenant["cycle"]
victim_cycle_issue = victim_tenant["cycle_issue"]
url = self.get_url(workspace.slug, attacker_project.id, attacker_cycle.id)
response = session_client.post(url, {"issues": [str(victim_issue.id)]}, format="json")
# The endpoint reports success regardless; the security property is that
# the foreign row is untouched.
assert response.status_code in (status.HTTP_201_CREATED, status.HTTP_200_OK), (
f"Got {response.status_code}: {response.data!r}"
)
victim_cycle_issue.refresh_from_db()
assert victim_cycle_issue.cycle_id == victim_cycle.id, (
"Cross-tenant reassignment: victim's CycleIssue was moved to the attacker's cycle"
)
# No CycleIssue for the victim's issue should exist under the attacker's cycle.
assert not CycleIssue.objects.filter(
cycle_id=attacker_cycle.id, issue_id=victim_issue.id
).exists()
@pytest.mark.django_db
def test_same_tenant_reassignment_still_works(
self, session_client, workspace, attacker_project, attacker_cycle, create_user
):
"""A legitimate reassignment within the caller's own project must still
move the issue into the target cycle — the scope guard must not break
the normal flow."""
state = State.objects.create(
name="Todo", project=attacker_project, workspace=workspace, group="backlog", default=True
)
own_issue = Issue.objects.create(
name="Own Issue",
workspace=workspace,
project=attacker_project,
state=state,
created_by=create_user,
)
old_cycle = Cycle.objects.create(
name="Old Cycle", project=attacker_project, workspace=workspace, owned_by=create_user
)
own_cycle_issue = CycleIssue.objects.create(
issue=own_issue,
cycle=old_cycle,
project=attacker_project,
workspace=workspace,
created_by=create_user,
)
url = self.get_url(workspace.slug, attacker_project.id, attacker_cycle.id)
response = session_client.post(url, {"issues": [str(own_issue.id)]}, format="json")
assert response.status_code in (status.HTTP_201_CREATED, status.HTTP_200_OK), (
f"Got {response.status_code}: {response.data!r}"
)
own_cycle_issue.refresh_from_db()
assert own_cycle_issue.cycle_id == attacker_cycle.id, (
"Legitimate same-project reassignment must still move the issue to the target cycle"
)

View File

@@ -0,0 +1,91 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for ``DeployBoardViewSet`` authorization.
Regression coverage for the app-side sibling of GHSA-w2vf-m9x9-mvmc (WEB-8075).
``DeployBoardViewSet`` uses ``ProjectMemberPermission`` whose SAFE_METHODS branch
previously checked only workspace membership, so a workspace member who was NOT
a member of a project could ``GET .../project-deploy-boards/`` and read that
project's publish configuration.
The fix scopes the SAFE_METHODS check to ``project_id=view.project_id`` so a
non-member is rejected with 403.
"""
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import Project, ProjectMember, User, WorkspaceMember
def deploy_board_url(slug, project_id):
return f"/api/workspaces/{slug}/projects/{project_id}/project-deploy-boards/"
@pytest.fixture
def project(db, workspace, create_user):
"""A project; ``create_user`` (session_client) is an active member."""
project = Project.objects.create(
name="Board Project",
identifier="BP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
return project
@pytest.fixture
def outsider_client(db, workspace, create_user):
"""Session client for a workspace member who is NOT in ``project``.
The outsider is made a member of an *unrelated* project so the vulnerable
SAFE_METHODS check (ProjectMember filtered by workspace only, no project_id)
would pass; without that the request is denied for simply having no project
membership, not for the cross-project scoping being fixed here.
"""
unique_id = uuid4().hex[:8]
outsider = User.objects.create(
email=f"outsider-{unique_id}@plane.so",
username=f"outsider_{unique_id}",
)
outsider.set_password("test-password")
outsider.save()
WorkspaceMember.objects.create(workspace=workspace, member=outsider, role=15)
other_project = Project.objects.create(
name="Outsider's Project",
identifier="OP",
workspace=workspace,
created_by=outsider,
)
ProjectMember.objects.create(
project=other_project, member=outsider, workspace=workspace, role=15
)
client = APIClient()
client.force_authenticate(user=outsider)
return client
@pytest.mark.contract
class TestDeployBoardProjectScope:
@pytest.mark.django_db
def test_non_project_member_cannot_read_deploy_board(self, outsider_client, workspace, project):
response = outsider_client.get(deploy_board_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_403_FORBIDDEN, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_project_member_can_read_deploy_board(self, session_client, workspace, project):
"""Positive control: an active project member is not blocked."""
response = session_client.get(deploy_board_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)

View File

@@ -0,0 +1,145 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for ``IssueListEndpoint`` guest scoping.
Regression coverage for GHSA-32c7-84jc-4w67 (WEB-8074). ``IssueListEndpoint.get``
(``/workspaces/<slug>/projects/<project_id>/issues/list/``) returned any issue
whose id was passed in ``?issues=``, without applying the guest ``created_by``
restriction that its sibling ``IssueViewSet.list`` enforces. A project GUEST on a
project with ``guest_view_all_features=False`` could therefore read issues they
did not author by supplying their ids.
The fix scopes the queryset to ``created_by=request.user`` for such guests,
mirroring ``IssueViewSet.list``.
"""
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
Issue,
Project,
ProjectMember,
User,
WorkspaceMember,
)
LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/list/"
@pytest.fixture
def project(db, workspace, create_user):
"""A project (guest_view_all_features defaults to False); owner is a member."""
project = Project.objects.create(
name="Scoped Project",
identifier="SP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
return project
@pytest.fixture
def guest(db, workspace, project):
"""An active project GUEST (role=5)."""
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"guest-{unique_id}@plane.so",
username=f"guest_{unique_id}",
first_name="Guest",
last_name="User",
)
user.set_password("test-password")
user.save()
WorkspaceMember.objects.create(workspace=workspace, member=user, role=5)
ProjectMember.objects.create(
project=project, member=user, workspace=workspace, role=5
)
return user
@pytest.fixture
def guest_client(guest):
client = APIClient()
client.force_authenticate(user=guest)
return client
def _make_issue(name, project, workspace, author):
"""Create an issue with a deterministic ``created_by``.
``BaseModel.save`` auto-sets ``created_by`` from the current request user
(None/anonymous under tests), so a ``created_by=`` kwarg to ``create`` is
overwritten. Passing ``created_by_id`` to ``save`` sets it explicitly.
"""
issue = Issue(name=name, project=project, workspace=workspace)
issue.save(created_by_id=author.id)
return issue
@pytest.fixture
def own_issue(db, workspace, project, guest):
"""An issue authored by the guest."""
return _make_issue("Guest's own issue", project, workspace, guest)
@pytest.fixture
def foreign_issue(db, workspace, project, create_user):
"""An issue authored by someone other than the guest."""
return _make_issue("Someone else's issue", project, workspace, create_user)
@pytest.mark.contract
class TestIssueListGuestScope:
"""A restricted guest must only get back issues they authored."""
@pytest.mark.django_db
def test_guest_cannot_read_foreign_issue(
self, guest_client, workspace, project, own_issue, foreign_issue
):
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
returned_ids = {str(row["id"]) for row in response.data}
assert str(own_issue.id) in returned_ids
assert str(foreign_issue.id) not in returned_ids, (
f"Guest read a foreign issue: {response.data!r}"
)
@pytest.mark.django_db
def test_project_member_reads_all_requested_issues(
self, session_client, workspace, project, own_issue, foreign_issue
):
"""Positive control: a full member (owner) still gets every requested issue."""
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
response = session_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
assert response.status_code == status.HTTP_200_OK
returned_ids = {str(row["id"]) for row in response.data}
assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids
@pytest.mark.django_db
def test_guest_with_view_all_reads_all_requested_issues(
self, guest_client, workspace, project, own_issue, foreign_issue
):
"""When guest_view_all_features is enabled, the guest sees all requested issues."""
project.guest_view_all_features = True
project.save(update_fields=["guest_view_all_features"])
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
assert response.status_code == status.HTTP_200_OK
returned_ids = {str(row["id"]) for row in response.data}
assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids

View File

@@ -0,0 +1,93 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Regression tests for page order_by ORM injection (WEB-8110).
PageViewSet.get_queryset passed the raw order_by query param into .order_by(),
which resolves field names at call time — an unknown field raised FieldError
(500 DoS) and a relation path (e.g. owned_by__password) enabled ORM relational
traversal. The param is now sanitized against PAGE_ORDER_BY_ALLOWLIST.
"""
import pytest
from rest_framework import status
from plane.db.models import Page, Project, ProjectMember, ProjectPage
@pytest.fixture
def project_with_page(db, workspace, create_user):
project = Project.objects.create(name="P", identifier="PRD", workspace=workspace)
ProjectMember.objects.create(
workspace=workspace, project=project, member=create_user, role=20, is_active=True
)
page = Page.objects.create(workspace=workspace, owned_by=create_user, access=Page.PUBLIC_ACCESS, name="pg")
ProjectPage.objects.create(workspace=workspace, project=project, page=page)
return project, page
def _pages_url(slug, project_id):
return f"/api/workspaces/{slug}/projects/{project_id}/pages/"
@pytest.mark.contract
class TestPageOrderByAllowlist:
@pytest.mark.django_db
@pytest.mark.parametrize(
"order_by",
[
"password", # invalid field → FieldError (500) pre-fix
"bogus__field__x", # invalid relation path → FieldError (500) pre-fix
"owned_by__password", # valid relation path → ORM traversal pre-fix
],
)
def test_malicious_order_by_is_rejected(self, session_client, workspace, project_with_page, order_by):
project, _ = project_with_page
response = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": order_by})
# Sanitized to the safe default — no 500, no traversal.
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
@pytest.mark.parametrize("order_by", ["name", "-name", "created_at", "-created_at", "updated_at", "sort_order"])
def test_allowlisted_order_by_is_accepted(self, session_client, workspace, project_with_page, order_by):
project, _ = project_with_page
response = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": order_by})
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_no_order_by_param_defaults_ok(self, session_client, workspace, project_with_page):
project, _ = project_with_page
response = session_client.get(_pages_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_allowlisted_order_by_actually_orders_results(self, session_client, workspace, create_user):
"""An allowlisted order_by must actually affect the result ordering —
guards against the param being silently overridden by a later
.order_by() call."""
project = Project.objects.create(name="P2", identifier="ORD", workspace=workspace)
ProjectMember.objects.create(
workspace=workspace, project=project, member=create_user, role=20, is_active=True
)
# Non-favorite public pages so the favourite-first primary sort is a
# no-op and the secondary (name) ordering is observable.
for name in ("Gamma", "Alpha", "Beta"):
page = Page.objects.create(
workspace=workspace, owned_by=create_user, access=Page.PUBLIC_ACCESS, name=name
)
ProjectPage.objects.create(workspace=workspace, project=project, page=page)
asc = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": "name"})
desc = session_client.get(_pages_url(workspace.slug, project.id), {"order_by": "-name"})
assert asc.status_code == status.HTTP_200_OK
assert [p["name"] for p in asc.json()] == ["Alpha", "Beta", "Gamma"]
assert [p["name"] for p in desc.json()] == ["Gamma", "Beta", "Alpha"]

View File

@@ -0,0 +1,149 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Regression tests for GHSA-g49r-p85q-qq2w / GHSA-ghcr-frqr-6pqr.
ProjectPagePermission verified that the caller was a member of the URL
project_id, but PageVersionEndpoint resolved the page (and its versions) by
workspace + page_id only. A member of one project could therefore read the
page versions of a public page belonging to a *different* project in the same
workspace via that project's URL.
"""
import uuid
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import (
Page,
PageVersion,
Project,
ProjectMember,
ProjectPage,
User,
)
def _page_versions_url(slug, project_id, page_id, pk=None):
base = f"/api/workspaces/{slug}/projects/{project_id}/pages/{page_id}/versions/"
return f"{base}{pk}/" if pk else base
def _make_project(workspace, identifier):
return Project.objects.create(
name=f"Project {identifier}",
identifier=identifier,
workspace=workspace,
)
def _make_page(workspace, project, owner, access=Page.PUBLIC_ACCESS):
page = Page.objects.create(
workspace=workspace,
owned_by=owner,
access=access,
name="Secret page",
)
ProjectPage.objects.create(workspace=workspace, project=project, page=page)
return page
def _make_version(workspace, page, owner):
return PageVersion.objects.create(
workspace=workspace,
page=page,
owned_by=owner,
description_html="<p>secret</p>",
)
@pytest.mark.contract
class TestPageVersionProjectScope:
"""The attacker (create_user / session_client) is an active member of
project_a only. Victim owns a public page in project_b."""
def _setup(self, workspace, attacker):
victim = User.objects.create(email="victim@plane.so", username=f"victim_{uuid.uuid4().hex[:8]}")
project_a = _make_project(workspace, "PRJA")
project_b = _make_project(workspace, "PRJB")
# Attacker is an active member of project A only.
ProjectMember.objects.create(workspace=workspace, project=project_a, member=attacker, role=20)
# Public page + version living in project B (attacker is NOT a member).
page_b = _make_page(workspace, project_b, victim)
version_b = _make_version(workspace, page_b, victim)
return victim, project_a, project_b, page_b, version_b
@pytest.mark.django_db
def test_cross_project_version_list_denied(self, session_client, workspace, create_user):
"""Listing another project's page versions via a project the attacker
belongs to must be denied (was a 200 leak)."""
_, project_a, _, page_b, _ = self._setup(workspace, create_user)
response = session_client.get(_page_versions_url(workspace.slug, project_a.id, page_b.id))
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_cross_project_version_detail_denied(self, session_client, workspace, create_user):
"""Reading a single cross-project page version must be denied."""
_, project_a, _, page_b, version_b = self._setup(workspace, create_user)
response = session_client.get(
_page_versions_url(workspace.slug, project_a.id, page_b.id, pk=version_b.id)
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_same_project_public_page_versions_allowed(self, session_client, workspace, create_user):
"""A public page that genuinely belongs to the attacker's project is
still readable, and its versions are returned."""
victim, project_a, _, _, _ = self._setup(workspace, create_user)
# Public page owned by the victim but linked to project A (attacker is a
# member of A). Exercises the public-page access branch (not owner).
page_a = _make_page(workspace, project_a, victim)
version_a = _make_version(workspace, page_a, victim)
response = session_client.get(_page_versions_url(workspace.slug, project_a.id, page_a.id))
assert response.status_code == status.HTTP_200_OK
returned_ids = {str(item["id"]) for item in response.json()}
assert str(version_a.id) in returned_ids
@pytest.mark.django_db
def test_revoked_project_link_denied(self, session_client, workspace, create_user):
"""A page whose ProjectPage link to the attacker's project was
soft-deleted (page removed from the project) must be denied, even
though the attacker is a member of that project."""
victim, project_a, _, _, _ = self._setup(workspace, create_user)
page = Page.objects.create(
workspace=workspace, owned_by=victim, access=Page.PUBLIC_ACCESS, name="Removed page"
)
# Link exists but is soft-deleted → the page no longer belongs to A.
ProjectPage.objects.create(
workspace=workspace, project=project_a, page=page, deleted_at=timezone.now()
)
_make_version(workspace, page, victim)
response = session_client.get(_page_versions_url(workspace.slug, project_a.id, page.id))
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_cross_project_version_list_not_a_member_anywhere(self, session_client, workspace, create_user):
"""Sanity: a project the attacker is not a member of is denied outright."""
_, _, project_b, page_b, _ = self._setup(workspace, create_user)
response = session_client.get(_page_versions_url(workspace.slug, project_b.id, page_b.id))
assert response.status_code == status.HTTP_403_FORBIDDEN

View File

@@ -0,0 +1,524 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from rest_framework import status
import uuid
from django.utils import timezone
from plane.db.models import (
Project,
ProjectMember,
ProjectUserProperty,
State,
WorkspaceMember,
User,
)
class TestProjectBase:
def get_project_url(self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False) -> str:
"""
Constructs the project endpoint URL for the given workspace as reverse() is
unreliable due to duplicate 'name' values in URL patterns ('api' and 'app').
Args:
workspace_slug (str): The slug of the workspace.
pk (uuid.UUID, optional): The primary key of a specific project.
details (bool, optional): If True, constructs the URL for the
project details endpoint. Defaults to False.
"""
# Establish the common base URL for all project-related endpoints.
base_url = f"/api/workspaces/{workspace_slug}/projects/"
# Specific project instance URL.
if pk:
return f"{base_url}{pk}/"
# Append 'details/' to the base URL.
if details:
return f"{base_url}details/"
# Return the base project list URL.
return base_url
@pytest.mark.contract
class TestProjectAPIPost(TestProjectBase):
"""Test project POST operations"""
@pytest.mark.django_db
def test_create_project_empty_data(self, session_client, workspace):
"""Test creating a project with empty data"""
url = self.get_project_url(workspace.slug)
# Test with empty data
response = session_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_create_project_valid_data(self, session_client, workspace, create_user):
url = self.get_project_url(workspace.slug)
project_data = {
"name": "New Project Test",
"identifier": "NPT",
}
user = create_user
# Make the request
response = session_client.post(url, project_data, format="json")
# Check response status
assert response.status_code == status.HTTP_201_CREATED
# Verify project was created
assert Project.objects.count() == 1
project = Project.objects.get(name=project_data["name"])
assert project.workspace == workspace
# Check if the member is created with the correct role
assert ProjectMember.objects.count() == 1
project_member = ProjectMember.objects.filter(project=project, member=user).first()
assert project_member.role == 20 # Administrator
assert project_member.is_active is True
# Verify ProjectUserProperty was created
assert ProjectUserProperty.objects.filter(project=project, user=user).exists()
# Verify default states were created
states = State.objects.filter(project=project)
assert states.count() == 5
expected_states = ["Backlog", "Todo", "In Progress", "Done", "Cancelled"]
state_names = list(states.values_list("name", flat=True))
assert set(state_names) == set(expected_states)
@pytest.mark.django_db
def test_create_project_with_project_lead(self, session_client, workspace, create_user):
"""Test creating project with a different project lead"""
# Create another user to be project lead
project_lead = User.objects.create_user(email="lead@example.com", username="projectlead")
# Add project lead to workspace
WorkspaceMember.objects.create(workspace=workspace, member=project_lead, role=15)
url = self.get_project_url(workspace.slug)
project_data = {
"name": "Project with Lead",
"identifier": "PWL",
"project_lead": project_lead.id,
}
response = session_client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
# Verify both creator and project lead are administrators
project = Project.objects.get(name=project_data["name"])
assert ProjectMember.objects.filter(project=project, role=20).count() == 2
# Verify both have ProjectUserProperty
assert ProjectUserProperty.objects.filter(project=project).count() == 2
@pytest.mark.django_db
def test_create_project_guest_forbidden(self, session_client, workspace):
"""Test that guests cannot create projects"""
guest_user = User.objects.create_user(email="guest@example.com", username="guest")
WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5)
session_client.force_authenticate(user=guest_user)
url = self.get_project_url(workspace.slug)
project_data = {
"name": "Guest Project",
"identifier": "GP",
}
response = session_client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Project.objects.count() == 0
@pytest.mark.django_db
def test_create_project_unauthenticated(self, client, workspace):
"""Test unauthenticated access"""
url = self.get_project_url(workspace.slug)
project_data = {
"name": "Unauth Project",
"identifier": "UP",
}
response = client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
def test_create_project_duplicate_name(self, session_client, workspace, create_user):
"""Test creating project with duplicate name"""
# Create first project
Project.objects.create(name="Duplicate Name", identifier="DN1", workspace=workspace)
url = self.get_project_url(workspace.slug)
project_data = {
"name": "Duplicate Name",
"identifier": "DN2",
}
response = session_client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_create_project_duplicate_identifier(self, session_client, workspace, create_user):
"""Test creating project with duplicate identifier"""
Project.objects.create(name="First Project", identifier="DUP", workspace=workspace)
url = self.get_project_url(workspace.slug)
project_data = {
"name": "Second Project",
"identifier": "DUP",
}
response = session_client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_create_project_missing_required_fields(self, session_client, workspace, create_user):
"""Test validation with missing required fields"""
url = self.get_project_url(workspace.slug)
# Test missing name
response = session_client.post(url, {"identifier": "MN"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Test missing identifier
response = session_client.post(url, {"name": "Missing Identifier"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_create_project_with_all_optional_fields(self, session_client, workspace, create_user):
"""Test creating project with all optional fields"""
url = self.get_project_url(workspace.slug)
project_data = {
"name": "Full Project",
"identifier": "FP",
"description": "A comprehensive test project",
"network": 2,
"cycle_view": True,
"issue_views_view": False,
"module_view": True,
"page_view": False,
"inbox_view": True,
"guest_view_all_features": True,
"logo_props": {
"in_use": "emoji",
"emoji": {"value": "🚀", "unicode": "1f680"},
},
}
response = session_client.post(url, project_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
response_data = response.json()
assert response_data["description"] == project_data["description"]
assert response_data["network"] == project_data["network"]
@pytest.mark.contract
class TestProjectAPIGet(TestProjectBase):
"""Test project GET operations"""
@pytest.mark.django_db
def test_list_projects_authenticated_admin(self, session_client, workspace, create_user):
"""Test listing projects as workspace admin"""
# Create a project
project = Project.objects.create(name="Test Project", identifier="TP", workspace=workspace)
# Add user as project member
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug)
response = session_client.get(url)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "Test Project"
assert data[0]["identifier"] == "TP"
@pytest.mark.django_db
def test_list_projects_authenticated_guest(self, session_client, workspace):
"""Test listing projects as workspace guest"""
# Create a guest user
guest_user = User.objects.create_user(email="guest@example.com", username="guest")
WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5, is_active=True)
# Create projects
project1 = Project.objects.create(name="Project 1", identifier="P1", workspace=workspace)
Project.objects.create(name="Project 2", identifier="P2", workspace=workspace)
# Add guest to only one project
ProjectMember.objects.create(project=project1, member=guest_user, role=10, is_active=True)
session_client.force_authenticate(user=guest_user)
url = self.get_project_url(workspace.slug)
response = session_client.get(url)
assert response.status_code == status.HTTP_200_OK
data = response.json()
# Guest should only see projects they're members of
assert len(data) == 1
assert data[0]["name"] == "Project 1"
@pytest.mark.django_db
def test_list_projects_unauthenticated(self, client, workspace):
"""Test listing projects without authentication"""
url = self.get_project_url(workspace.slug)
response = client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
def test_list_detail_projects(self, session_client, workspace, create_user):
"""Test listing projects with detailed information"""
# Create a project
project = Project.objects.create(
name="Detailed Project",
identifier="DP",
workspace=workspace,
description="A detailed test project",
)
# Add user as project member
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, details=True)
response = session_client.get(url)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "Detailed Project"
assert data[0]["description"] == "A detailed test project"
@pytest.mark.django_db
def test_retrieve_project_success(self, session_client, workspace, create_user):
"""Test retrieving a specific project"""
# Create a project
project = Project.objects.create(
name="Retrieve Test Project",
identifier="RTP",
workspace=workspace,
description="Test project for retrieval",
)
# Add user as project member
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, pk=project.id)
response = session_client.get(url)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["name"] == "Retrieve Test Project"
assert data["identifier"] == "RTP"
assert data["description"] == "Test project for retrieval"
@pytest.mark.django_db
def test_retrieve_project_not_found(self, session_client, workspace, create_user):
"""Test retrieving a non-existent project"""
fake_uuid = uuid.uuid4()
url = self.get_project_url(workspace.slug, pk=fake_uuid)
response = session_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_retrieve_archived_project(self, session_client, workspace, create_user):
"""Test retrieving an archived project"""
# Create an archived project
project = Project.objects.create(
name="Archived Project",
identifier="AP",
workspace=workspace,
archived_at=timezone.now(),
)
# Add user as project member
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, pk=project.id)
response = session_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.contract
class TestProjectAPIPatchDelete(TestProjectBase):
"""Test project PATCH, and DELETE operations"""
@pytest.mark.django_db
def test_partial_update_project_success(self, session_client, workspace, create_user):
"""Test successful partial update of project"""
# Create a project
project = Project.objects.create(
name="Original Project",
identifier="OP",
workspace=workspace,
description="Original description",
)
# Add user as project administrator
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, pk=project.id)
update_data = {
"name": "Updated Project",
"description": "Updated description",
"cycle_view": True,
"module_view": False,
}
response = session_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_200_OK
# Verify project was updated
project.refresh_from_db()
assert project.name == "Updated Project"
assert project.description == "Updated description"
assert project.cycle_view is True
assert project.module_view is False
@pytest.mark.django_db
def test_partial_update_project_forbidden_non_admin(self, session_client, workspace):
"""Test that non-admin project members cannot update project"""
# Create a project
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
# Create a member user (not admin)
member_user = User.objects.create_user(email="member@example.com", username="member")
WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True)
ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True)
session_client.force_authenticate(user=member_user)
url = self.get_project_url(workspace.slug, pk=project.id)
update_data = {"name": "Hacked Project"}
response = session_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_partial_update_duplicate_name_conflict(self, session_client, workspace, create_user):
"""Test updating project with duplicate name returns conflict"""
# Create two projects
Project.objects.create(name="Project One", identifier="P1", workspace=workspace)
project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace)
ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, pk=project2.id)
update_data = {"name": "Project One"} # Duplicate name
response = session_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_partial_update_duplicate_identifier_conflict(self, session_client, workspace, create_user):
"""Test updating project with duplicate identifier returns conflict"""
# Create two projects
Project.objects.create(name="Project One", identifier="P1", workspace=workspace)
project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace)
ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, pk=project2.id)
update_data = {"identifier": "P1"} # Duplicate identifier
response = session_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_partial_update_invalid_data(self, session_client, workspace, create_user):
"""Test partial update with invalid data"""
project = Project.objects.create(name="Valid Project", identifier="VP", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, pk=project.id)
update_data = {"name": ""}
response = session_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_delete_project_success_project_admin(self, session_client, workspace, create_user):
"""Test successful project deletion by project admin"""
project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
url = self.get_project_url(workspace.slug, pk=project.id)
response = session_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Project.objects.filter(id=project.id).exists()
@pytest.mark.django_db
def test_delete_project_success_workspace_admin(self, session_client, workspace):
"""Test successful project deletion by workspace admin"""
# Create workspace admin user
workspace_admin = User.objects.create_user(email="admin@example.com", username="admin")
WorkspaceMember.objects.create(workspace=workspace, member=workspace_admin, role=20, is_active=True)
project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace)
session_client.force_authenticate(user=workspace_admin)
url = self.get_project_url(workspace.slug, pk=project.id)
response = session_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Project.objects.filter(id=project.id).exists()
@pytest.mark.django_db
def test_delete_project_forbidden_non_admin(self, session_client, workspace):
"""Test that non-admin users cannot delete projects"""
# Create a member user (not admin)
member_user = User.objects.create_user(email="member@example.com", username="member")
WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True)
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True)
session_client.force_authenticate(user=member_user)
url = self.get_project_url(workspace.slug, pk=project.id)
response = session_client.delete(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Project.objects.filter(id=project.id).exists()
@pytest.mark.django_db
def test_delete_project_unauthenticated(self, client, workspace):
"""Test unauthenticated project deletion"""
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
url = self.get_project_url(workspace.slug, pk=project.id)
response = client.delete(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert Project.objects.filter(id=project.id).exists()

View File

@@ -0,0 +1,172 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Regression tests for GHSA-hpgm-9r34-c4x5 / GHSA-25gg-cxm8-g7h9.
A project GUEST (or MEMBER) must not be able to (de)activate other project
members by PATCHing ``{"is_active": false}`` while omitting the ``role`` field.
Before the fix, every authorization guard in ``ProjectMemberViewSet.partial_update``
lived inside ``if "role" in request.data:`` and ``is_active`` was writable through
``ProjectMemberSerializer(fields="__all__")`` — so a guest could deactivate any
member, including admins, and take over the project.
"""
import uuid
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
Project,
ProjectMember,
User,
WorkspaceMember,
)
def _member_detail_url(slug: str, project_id: uuid.UUID, pk: uuid.UUID) -> str:
return f"/api/workspaces/{slug}/projects/{project_id}/members/{pk}/"
def _make_user(email: str) -> User:
local_part = email.split("@")[0]
user = User.objects.create(email=email, username=local_part, first_name=local_part)
user.set_password("test-password")
user.save()
return user
def _add_member(workspace, project, user, *, ws_role: int, project_role: int) -> ProjectMember:
WorkspaceMember.objects.create(workspace=workspace, member=user, role=ws_role, is_active=True)
return ProjectMember.objects.create(
workspace=workspace, project=project, member=user, role=project_role, is_active=True
)
@pytest.fixture
def project(db, workspace, create_user):
"""A project owned by ``create_user`` (workspace owner / admin)."""
project = Project.objects.create(
name="Secure Project",
identifier="SEC",
workspace=workspace,
created_by=create_user,
)
# create_user is the workspace owner (role=20 via the workspace fixture);
# make them a project ADMIN too — this is the takeover victim.
ProjectMember.objects.create(
workspace=workspace, project=project, member=create_user, role=20, is_active=True
)
return project
@pytest.mark.contract
@pytest.mark.django_db
class TestProjectMemberIsActiveAuthz:
def test_guest_cannot_deactivate_admin(self, workspace, project, create_user):
"""A project GUEST must not deactivate a project ADMIN via is_active."""
attacker = _make_user("guest-attacker@plane.so")
# non-workspace-admin (role 15) so is_workspace_admin bypass does not apply,
# project GUEST (role 5)
_add_member(workspace, project, attacker, ws_role=15, project_role=5)
victim = ProjectMember.objects.get(project=project, member=create_user)
client = APIClient()
client.force_authenticate(user=attacker)
response = client.patch(
_member_detail_url(workspace.slug, project.id, victim.id),
{"is_active": False},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
victim.refresh_from_db()
assert victim.is_active is True
def test_member_cannot_deactivate_admin(self, workspace, project, create_user):
"""A project MEMBER must not deactivate a project ADMIN via is_active."""
attacker = _make_user("member-attacker@plane.so")
_add_member(workspace, project, attacker, ws_role=15, project_role=15)
victim = ProjectMember.objects.get(project=project, member=create_user)
client = APIClient()
client.force_authenticate(user=attacker)
response = client.patch(
_member_detail_url(workspace.slug, project.id, victim.id),
{"is_active": False},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
victim.refresh_from_db()
assert victim.is_active is True
def test_guest_cannot_deactivate_peer_guest(self, workspace, project):
"""A GUEST cannot deactivate another GUEST either (role check applies to all)."""
attacker = _make_user("guest-a@plane.so")
peer = _make_user("guest-b@plane.so")
_add_member(workspace, project, attacker, ws_role=15, project_role=5)
peer_member = _add_member(workspace, project, peer, ws_role=15, project_role=5)
client = APIClient()
client.force_authenticate(user=attacker)
response = client.patch(
_member_detail_url(workspace.slug, project.id, peer_member.id),
{"is_active": False},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
peer_member.refresh_from_db()
assert peer_member.is_active is True
def test_project_admin_can_deactivate_member(self, workspace, project):
"""Positive control: a project ADMIN (non-workspace-admin) may deactivate a MEMBER."""
admin = _make_user("project-admin@plane.so")
target = _make_user("plain-member@plane.so")
# admin is a workspace MEMBER (15) but project ADMIN (20) — exercises the
# role-comparison guard rather than the workspace-admin bypass.
_add_member(workspace, project, admin, ws_role=15, project_role=20)
target_member = _add_member(workspace, project, target, ws_role=15, project_role=15)
client = APIClient()
client.force_authenticate(user=admin)
response = client.patch(
_member_detail_url(workspace.slug, project.id, target_member.id),
{"is_active": False},
format="json",
)
assert response.status_code == status.HTTP_200_OK
target_member.refresh_from_db()
assert target_member.is_active is False
def test_workspace_admin_with_low_project_role_can_deactivate(self, workspace, project, create_user):
"""
Positive control: the intended workspace-admin bypass is preserved.
A workspace ADMIN (role 20) may deactivate any project member — even a
project ADMIN — despite holding only a project GUEST role, because
is_workspace_admin short-circuits the role-comparison guard. Locks in the
bypass so future changes don't silently remove it.
"""
ws_admin = _make_user("ws-admin@plane.so")
# workspace ADMIN (20) but only a project GUEST (5)
_add_member(workspace, project, ws_admin, ws_role=20, project_role=5)
# victim is the project ADMIN (create_user) set up by the `project` fixture
victim = ProjectMember.objects.get(project=project, member=create_user)
client = APIClient()
client.force_authenticate(user=ws_admin)
response = client.patch(
_member_detail_url(workspace.slug, project.id, victim.id),
{"is_active": False},
format="json",
)
assert response.status_code == status.HTTP_200_OK
victim.refresh_from_db()
assert victim.is_active is False

View File

@@ -0,0 +1,575 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for public Spaces board object-ID scoping.
Regression coverage for WEB-8283.
The public Spaces board write endpoints (``/api/public/anchor/<anchor>/...``)
resolve the ``DeployBoard`` from the URL ``anchor`` but previously trusted the
caller-supplied ``issue_id`` / ``comment_id`` / ``intake_id`` verbatim, without
verifying the target object belonged to that board's project/workspace. Any
authenticated user could therefore write a comment / reaction / vote onto an
arbitrary issue in any project (cross-tenant), and read EXTERNAL comments from a
different project in the same workspace.
The fix binds every caller-supplied object id to the board's project + workspace
before writing, and scopes the comment-list read to the board's project.
"""
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
CommentReaction,
DeployBoard,
Intake,
Issue,
IssueComment,
IssueReaction,
Project,
ProjectMember,
State,
User,
Workspace,
WorkspaceMember,
)
# --------------------------------------------------------------------------- #
# URL helpers
# --------------------------------------------------------------------------- #
def comments_url(anchor, issue_id):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/"
def issue_reactions_url(anchor, issue_id):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/"
def comment_reactions_url(anchor, comment_id):
return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/"
def votes_url(anchor, issue_id):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/votes/"
def intake_issues_url(anchor, intake_id):
return f"/api/public/anchor/{anchor}/intakes/{intake_id}/intake-issues/"
def comment_detail_url(anchor, issue_id, pk):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/{pk}/"
def issue_reaction_detail_url(anchor, issue_id, reaction_code):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/{reaction_code}/"
def comment_reaction_detail_url(anchor, comment_id, reaction_code):
return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/{reaction_code}/"
# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def _no_activity(db):
"""Stub the deferred activity task so writes never touch the broker."""
with (
mock.patch("plane.space.views.issue.issue_activity"),
mock.patch("plane.space.views.intake.issue_activity"),
):
yield
@pytest.fixture
def board(db, workspace, create_user):
"""A published project board (comments/reactions/votes + intake enabled).
``create_user`` (session_client) is an active member of this project.
"""
project = Project.objects.create(
name="Board Project", identifier="BRD", workspace=workspace, created_by=create_user
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20, is_active=True
)
state = State.objects.create(
name="Todo", project=project, workspace=workspace, group="backlog", default=True
)
issue = Issue.objects.create(
name="Board Issue", workspace=workspace, project=project, state=state, created_by=create_user
)
comment = IssueComment.objects.create(
issue=issue,
project=project,
workspace=workspace,
comment_html="<p>board external comment</p>",
access="EXTERNAL",
created_by=create_user,
actor=create_user,
)
intake = Intake.objects.create(name="Board Intake", project=project, workspace=workspace)
deploy_board = DeployBoard.objects.create(
entity_name="project",
entity_identifier=project.id,
project=project,
workspace=workspace,
is_comments_enabled=True,
is_reactions_enabled=True,
is_votes_enabled=True,
intake=intake,
)
return {
"project": project,
"issue": issue,
"comment": comment,
"intake": intake,
"anchor": deploy_board.anchor,
}
@pytest.fixture
def victim(db, workspace, create_user):
"""A *different* project in the SAME workspace, not published on ``board``."""
project = Project.objects.create(
name="Victim Project", identifier="VIC", workspace=workspace, created_by=create_user
)
state = State.objects.create(
name="Todo", project=project, workspace=workspace, group="backlog", default=True
)
issue = Issue.objects.create(
name="Victim Issue", workspace=workspace, project=project, state=state, created_by=create_user
)
comment = IssueComment.objects.create(
issue=issue,
project=project,
workspace=workspace,
comment_html="<p>secret external comment</p>",
access="EXTERNAL",
created_by=create_user,
actor=create_user,
)
intake = Intake.objects.create(name="Victim Intake", project=project, workspace=workspace)
return {"project": project, "issue": issue, "comment": comment, "intake": intake}
@pytest.fixture
def victim_other_ws(db, create_user):
"""A project in a DIFFERENT workspace — exercises the workspace_id binding
(true cross-tenant, matching the advisory's stated impact)."""
uid = uuid4().hex[:8]
owner = User.objects.create(email=f"victim-owner-{uid}@plane.so", username=f"victim_owner_{uid}")
owner.set_password("test-password")
owner.save()
other_ws = Workspace.objects.create(name="Other WS", owner=owner, slug=f"other-ws-{uid}")
WorkspaceMember.objects.create(workspace=other_ws, member=owner, role=20)
project = Project.objects.create(
name="Other WS Project", identifier="OWP", workspace=other_ws, created_by=owner
)
state = State.objects.create(
name="Todo", project=project, workspace=other_ws, group="backlog", default=True
)
issue = Issue.objects.create(
name="Other WS Issue", workspace=other_ws, project=project, state=state, created_by=owner
)
return {"workspace": other_ws, "project": project, "issue": issue}
@pytest.fixture
def board_votes_disabled(db, workspace, create_user):
"""A published board with voting DISABLED, for the is_votes_enabled gate."""
project = Project.objects.create(
name="No-Vote Project", identifier="NVP", workspace=workspace, created_by=create_user
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20, is_active=True
)
state = State.objects.create(
name="Todo", project=project, workspace=workspace, group="backlog", default=True
)
issue = Issue.objects.create(
name="No-Vote Issue", workspace=workspace, project=project, state=state, created_by=create_user
)
deploy_board = DeployBoard.objects.create(
entity_name="project",
entity_identifier=project.id,
project=project,
workspace=workspace,
is_comments_enabled=True,
is_reactions_enabled=True,
is_votes_enabled=False,
intake=None,
)
return {"project": project, "issue": issue, "anchor": deploy_board.anchor}
@pytest.fixture
def attacker_client(db, workspace):
"""An authenticated user who is NOT a member of the victim project."""
uid = uuid4().hex[:8]
user = User.objects.create(email=f"attacker-{uid}@plane.so", username=f"attacker_{uid}")
user.set_password("test-password")
user.save()
WorkspaceMember.objects.create(workspace=workspace, member=user, role=15)
client = APIClient()
client.force_authenticate(user=user)
return client
@pytest.fixture
def internal_comment(db, workspace, board, create_user):
"""An INTERNAL (non-public) comment on the board's own issue, with its own
reaction - never shown on the public board, so it must not be readable or
mutable through the public reaction endpoints either."""
comment = IssueComment.objects.create(
issue=board["issue"],
project=board["project"],
workspace=workspace,
comment_html="<p>internal only</p>",
access="INTERNAL",
created_by=create_user,
actor=create_user,
)
reaction = CommentReaction.objects.create(
project=board["project"],
workspace=workspace,
comment=comment,
actor=create_user,
reaction="fire",
)
return {"comment": comment, "reaction": reaction}
@pytest.fixture
def board_issue_reaction(db, workspace, board, create_user):
"""A reaction already on the board's own issue, for destroy() coverage."""
return IssueReaction.objects.create(
project=board["project"],
workspace=workspace,
issue=board["issue"],
actor=create_user,
reaction="smile",
)
@pytest.fixture
def victim_issue_reaction(db, victim, create_user):
"""A reaction the same user owns, but on an issue in a DIFFERENT project
(not published by ``board``) - exercises the project binding on destroy()."""
return IssueReaction.objects.create(
project=victim["project"],
workspace=victim["project"].workspace,
issue=victim["issue"],
actor=create_user,
reaction="smile",
)
# --------------------------------------------------------------------------- #
# Cross-tenant WRITE — must be rejected (404 for issue/comment binding,
# 400 for the intake binding mismatch, per each endpoint's contract below)
# --------------------------------------------------------------------------- #
@pytest.mark.contract
class TestSpacesBoardObjectScope:
@pytest.mark.django_db
def test_cannot_comment_on_issue_outside_board_project(self, attacker_client, board, victim):
response = attacker_client.post(
comments_url(board["anchor"], victim["issue"].id),
{"comment_html": "<p>injected</p>"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert not IssueComment.objects.filter(
issue_id=victim["issue"].id, comment_html="<p>injected</p>"
).exists()
@pytest.mark.django_db
def test_cannot_react_to_issue_outside_board_project(self, attacker_client, board, victim):
response = attacker_client.post(
issue_reactions_url(board["anchor"], victim["issue"].id),
{"reaction": "smile"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_cannot_vote_on_issue_outside_board_project(self, attacker_client, board, victim):
response = attacker_client.post(
votes_url(board["anchor"], victim["issue"].id),
{"vote": 1},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_cannot_react_to_comment_outside_board_project(self, attacker_client, board, victim):
response = attacker_client.post(
comment_reactions_url(board["anchor"], victim["comment"].id),
{"reaction": "smile"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_cannot_create_intake_issue_with_foreign_intake(self, attacker_client, board, victim):
response = attacker_client.post(
intake_issues_url(board["anchor"], victim["intake"].id),
{"issue": {"name": "injected intake"}},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_cannot_comment_on_issue_in_other_workspace(self, attacker_client, board, victim_other_ws):
"""The guard binds on workspace_id too — a board's anchor cannot reach an
issue in a different workspace (true cross-tenant vector)."""
response = attacker_client.post(
comments_url(board["anchor"], victim_other_ws["issue"].id),
{"comment_html": "<p>injected cross-ws</p>"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert not IssueComment.objects.filter(
issue_id=victim_other_ws["issue"].id, comment_html="<p>injected cross-ws</p>"
).exists()
# ----------------------------------------------------------------------- #
# Feature gate — vote create must honor is_votes_enabled (parity fix)
# ----------------------------------------------------------------------- #
@pytest.mark.django_db
def test_cannot_vote_when_votes_disabled(self, session_client, board_votes_disabled):
response = session_client.post(
votes_url(board_votes_disabled["anchor"], board_votes_disabled["issue"].id),
{"vote": 1},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
# ----------------------------------------------------------------------- #
# Cross-project READ leak — comment list must not surface other projects
# ----------------------------------------------------------------------- #
@pytest.mark.django_db
def test_comment_list_does_not_leak_cross_project(self, attacker_client, board, victim):
response = attacker_client.get(comments_url(board["anchor"], victim["issue"].id))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
results = response.data["results"] if isinstance(response.data, dict) else response.data
returned_ids = {str(c["id"]) for c in results}
assert str(victim["comment"].id) not in returned_ids, (
"Victim project's EXTERNAL comment leaked through another board's anchor"
)
@pytest.mark.django_db
def test_comment_list_returns_own_project_comments(self, session_client, board):
"""Positive control: the board's own EXTERNAL comments are still listed
(guards against an over-broad project_id filter returning nothing)."""
response = session_client.get(comments_url(board["anchor"], board["issue"].id))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
results = response.data["results"] if isinstance(response.data, dict) else response.data
returned_ids = {str(c["id"]) for c in results}
assert str(board["comment"].id) in returned_ids, (
"Board's own EXTERNAL comment was wrongly filtered out"
)
# ----------------------------------------------------------------------- #
# Positive controls — legitimate writes on the board's own issue still work
# ----------------------------------------------------------------------- #
@pytest.mark.django_db
def test_can_comment_on_issue_in_board_project(self, session_client, board):
response = session_client.post(
comments_url(board["anchor"], board["issue"].id),
{"comment_html": "<p>legit</p>"},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_can_vote_on_issue_in_board_project(self, session_client, board):
response = session_client.post(
votes_url(board["anchor"], board["issue"].id),
{"vote": 1},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_can_react_to_issue_in_board_project(self, session_client, board):
response = session_client.post(
issue_reactions_url(board["anchor"], board["issue"].id),
{"reaction": "smile"},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_can_react_to_comment_in_board_project(self, session_client, board):
response = session_client.post(
comment_reactions_url(board["anchor"], board["comment"].id),
{"reaction": "smile"},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_can_create_intake_issue_with_own_intake(self, session_client, board):
response = session_client.post(
intake_issues_url(board["anchor"], board["intake"].id),
{"issue": {"name": "legit intake issue"}},
format="json",
)
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
# --------------------------------------------------------------------------- #
# Read/update/delete scope gaps — the board-scope guards were only applied to
# create(). A caller could still read reactions on an INTERNAL (non-public)
# comment, or reach a comment/reaction they authored through a board it does
# not belong to, via list/update/delete. These mirror the create() coverage
# above for the list/partial_update/destroy paths.
# --------------------------------------------------------------------------- #
@pytest.mark.contract
class TestSpacesBoardObjectScopeMutations:
# ----------------------------------------------------------------------- #
# CommentReactionPublicViewSet — INTERNAL comments must stay invisible
# ----------------------------------------------------------------------- #
@pytest.mark.django_db
def test_comment_reaction_list_does_not_leak_internal_comment(self, session_client, board, internal_comment):
response = session_client.get(comment_reactions_url(board["anchor"], internal_comment["comment"].id))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
results = response.data["results"] if isinstance(response.data, dict) else response.data
returned_ids = {str(r["id"]) for r in results}
assert str(internal_comment["reaction"].id) not in returned_ids, (
"Reaction on an INTERNAL comment leaked through the public reaction list"
)
@pytest.mark.django_db
def test_cannot_delete_reaction_on_internal_comment(self, session_client, board, internal_comment):
response = session_client.delete(
comment_reaction_detail_url(board["anchor"], internal_comment["comment"].id, "fire")
)
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert CommentReaction.objects.filter(id=internal_comment["reaction"].id).exists(), (
"Reaction on an INTERNAL comment was deleted through the public board"
)
@pytest.mark.django_db
def test_can_delete_own_reaction_on_external_comment(self, session_client, board, create_user):
reaction = CommentReaction.objects.create(
project=board["project"],
workspace=board["project"].workspace,
comment=board["comment"],
actor=create_user,
reaction="tada",
)
response = session_client.delete(comment_reaction_detail_url(board["anchor"], board["comment"].id, "tada"))
assert response.status_code == status.HTTP_204_NO_CONTENT, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert not CommentReaction.objects.filter(id=reaction.id).exists()
# ----------------------------------------------------------------------- #
# IssueCommentPublicViewSet.partial_update / destroy — must bind pk to
# this board's project + workspace + issue_id, not just the actor
# ----------------------------------------------------------------------- #
@pytest.mark.django_db
def test_cannot_update_comment_reached_through_wrong_board(self, session_client, board, victim):
response = session_client.patch(
comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id),
{"comment_html": "<p>hijacked</p>"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
victim["comment"].refresh_from_db()
assert victim["comment"].comment_html != "<p>hijacked</p>"
@pytest.mark.django_db
def test_cannot_delete_comment_reached_through_wrong_board(self, session_client, board, victim):
response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id))
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert IssueComment.objects.filter(id=victim["comment"].id).exists()
@pytest.mark.django_db
def test_can_update_own_comment_in_board_project(self, session_client, board):
response = session_client.patch(
comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id),
{"comment_html": "<p>edited</p>"},
format="json",
)
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
@pytest.mark.django_db
def test_can_delete_own_comment_in_board_project(self, session_client, board):
response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id))
assert response.status_code == status.HTTP_204_NO_CONTENT, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert not IssueComment.objects.filter(id=board["comment"].id).exists()
# ----------------------------------------------------------------------- #
# IssueReactionPublicViewSet.destroy — must bind to project, not just
# workspace
# ----------------------------------------------------------------------- #
@pytest.mark.django_db
def test_cannot_delete_reaction_on_issue_outside_board_project(
self, session_client, board, victim, victim_issue_reaction
):
response = session_client.delete(issue_reaction_detail_url(board["anchor"], victim["issue"].id, "smile"))
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert IssueReaction.objects.filter(id=victim_issue_reaction.id).exists(), (
"Reaction on an issue outside the board's project was deleted through this board"
)
@pytest.mark.django_db
def test_can_delete_own_reaction_on_issue_in_board_project(self, session_client, board, board_issue_reaction):
response = session_client.delete(issue_reaction_detail_url(board["anchor"], board["issue"].id, "smile"))
assert response.status_code == status.HTTP_204_NO_CONTENT, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert not IssueReaction.objects.filter(id=board_issue_reaction.id).exists()

View File

@@ -0,0 +1,224 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for ``SubIssuesEndpoint`` cross-project scoping.
``SubIssuesEndpoint``
(``/workspaces/<slug>/projects/<project_id>/issues/<issue_id>/sub-issues/``) is
guarded only by ``ProjectEntityPermission``, which checks that the caller is a
member of the URL ``project_id`` — not that ``issue_id`` belongs to it. Both
handlers then resolved issues without scoping to the URL project:
* GET filtered sub-issues by ``parent_id`` + ``workspace__slug`` only, leaking
the titles/metadata of another project's sub-issues (read IDOR).
* POST loaded the parent by bare ``pk`` (no workspace/project scope) and filtered
the moved sub-issues by ``workspace__slug`` only, letting a member re-parent
issues from other projects/workspaces (write IDOR).
The fix scopes every lookup to the URL ``project_id`` (and binds the parent to
the workspace), so a caller can only ever touch sub-issues of the project they
are actually a member of.
"""
import pytest
from rest_framework import status
from plane.db.models import (
Issue,
Project,
ProjectMember,
)
SUB_ISSUES_URL = (
"/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/sub-issues/"
)
def _make_issue(name, project, workspace, author, parent=None):
"""Create an issue with a deterministic ``created_by``.
``BaseModel.save`` auto-sets ``created_by`` from the current request user
(None/anonymous under tests), so a ``created_by=`` kwarg to ``create`` is
overwritten. Passing ``created_by_id`` to ``save`` sets it explicitly.
"""
issue = Issue(name=name, project=project, workspace=workspace, parent=parent)
issue.save(created_by_id=author.id)
return issue
@pytest.fixture
def project_a(db, workspace, create_user):
"""The project the caller is a member of (the URL project)."""
project = Project.objects.create(
name="Project A",
identifier="PA",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
return project
@pytest.fixture
def project_b(db, workspace, create_user):
"""A sibling project in the same workspace the caller is NOT a member of."""
return Project.objects.create(
name="Project B",
identifier="PB",
workspace=workspace,
created_by=create_user,
)
# --- Project B (victim) issues -------------------------------------------------
@pytest.fixture
def parent_b(db, workspace, project_b, create_user):
return _make_issue("B parent", project_b, workspace, create_user)
@pytest.fixture
def sub_b(db, workspace, project_b, parent_b, create_user):
return _make_issue("B sub-issue", project_b, workspace, create_user, parent=parent_b)
@pytest.fixture
def orphan_b(db, workspace, project_b, create_user):
"""A standalone (unparented) issue in project B — target of a write IDOR."""
return _make_issue("B orphan", project_b, workspace, create_user)
# --- Project A (caller's) issues ----------------------------------------------
@pytest.fixture
def parent_a(db, workspace, project_a, create_user):
return _make_issue("A parent", project_a, workspace, create_user)
@pytest.fixture
def sub_a(db, workspace, project_a, parent_a, create_user):
return _make_issue("A sub-issue", project_a, workspace, create_user, parent=parent_a)
@pytest.fixture
def orphan_a(db, workspace, project_a, create_user):
return _make_issue("A orphan", project_a, workspace, create_user)
@pytest.mark.contract
class TestSubIssuesCrossProjectScope:
"""A project member must not read or write another project's sub-issue graph."""
@pytest.mark.django_db
def test_read_cross_project_sub_issues_hidden(
self, session_client, workspace, project_a, parent_b, sub_b
):
"""GET with a parent that lives in a project the caller isn't in leaks nothing.
The URL project is A (caller is a member); the parent issue lives in B.
Before the fix the endpoint returned B's sub-issues; now the project scope
excludes them.
"""
url = SUB_ISSUES_URL.format(
slug=workspace.slug, project_id=project_a.id, issue_id=parent_b.id
)
response = session_client.get(url)
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
returned_ids = {str(row["id"]) for row in response.data["sub_issues"]}
assert str(sub_b.id) not in returned_ids, (
f"Leaked cross-project sub-issue: {response.data!r}"
)
@pytest.mark.django_db
def test_write_cross_project_reparent_blocked(
self, session_client, workspace, project_a, parent_b, orphan_b
):
"""POST cannot re-parent an issue onto a parent outside the URL project.
Parent B is not in project A, so the scoped lookup 404s and no issue is
moved. Before the fix the parent resolved by bare pk and the orphan was
re-parented.
"""
url = SUB_ISSUES_URL.format(
slug=workspace.slug, project_id=project_a.id, issue_id=parent_b.id
)
response = session_client.post(url, {"sub_issue_ids": [str(orphan_b.id)]}, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND, (
f"Expected 404, got {response.status_code}: {getattr(response, 'data', None)!r}"
)
orphan_b.refresh_from_db()
assert orphan_b.parent_id is None, "Orphan issue was re-parented across projects"
@pytest.mark.django_db
def test_write_cross_project_sub_issue_ids_ignored(
self, session_client, workspace, project_a, parent_a, orphan_b, mocker
):
"""Even with an in-project parent, sub_issue_ids from another project are ignored.
Parent A is valid for the URL project, but ``orphan_b`` lives in project B,
so the project-scoped ``sub_issue_ids`` filter must exclude it and leave its
parent untouched — and no activity event may be enqueued for it (the activity
task does an unscoped lookup + updated_at bump on whatever id it receives).
"""
mock_activity = mocker.patch(
"plane.app.views.issue.sub_issue.issue_activity.delay"
)
url = SUB_ISSUES_URL.format(
slug=workspace.slug, project_id=project_a.id, issue_id=parent_a.id
)
response = session_client.post(url, {"sub_issue_ids": [str(orphan_b.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
# The foreign issue must not appear in the response body either.
assert response.data["sub_issues"] == [], f"Leaked cross-project issue: {response.data!r}"
assert response.data["state_distribution"] == {}, f"Leaked state_distribution: {response.data!r}"
orphan_b.refresh_from_db()
assert orphan_b.parent_id is None, "Cross-project issue was re-parented"
# No activity may be dispatched for the excluded cross-project issue.
dispatched_ids = {call.kwargs.get("issue_id") for call in mock_activity.call_args_list}
assert str(orphan_b.id) not in dispatched_ids, (
f"Activity dispatched for a cross-project issue: {mock_activity.call_args_list!r}"
)
# --- Positive controls: legitimate same-project use still works -----------
@pytest.mark.django_db
def test_read_same_project_sub_issues_visible(
self, session_client, workspace, project_a, parent_a, sub_a
):
url = SUB_ISSUES_URL.format(
slug=workspace.slug, project_id=project_a.id, issue_id=parent_a.id
)
response = session_client.get(url)
assert response.status_code == status.HTTP_200_OK
returned_ids = {str(row["id"]) for row in response.data["sub_issues"]}
assert str(sub_a.id) in returned_ids, (
f"Expected sub-issue {sub_a.id} in {response.data!r}"
)
@pytest.mark.django_db
def test_write_same_project_reparent_allowed(
self, session_client, workspace, project_a, parent_a, orphan_a
):
url = SUB_ISSUES_URL.format(
slug=workspace.slug, project_id=project_a.id, issue_id=parent_a.id
)
response = session_client.post(url, {"sub_issue_ids": [str(orphan_a.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
orphan_a.refresh_from_db()
assert str(orphan_a.parent_id) == str(parent_a.id), "Same-project re-parent did not persist"

View File

@@ -0,0 +1,106 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Regression tests for WEB-8103.
WebhookEndpoint list/retrieve/patch passed a fields= allowlist that excluded
secret_key, but DynamicBaseSerializer.__init__ discards the allowlist, so the
HMAC secret_key leaked on every webhook read. WebhookSerializer now hides
secret_key by default and only reveals it on create / secret regeneration.
"""
import pytest
from rest_framework import status
from plane.app.serializers import WebhookSerializer
from plane.db.models import Webhook
def _webhooks_url(slug, pk=None, regenerate=False):
base = f"/api/workspaces/{slug}/webhooks/"
if pk and regenerate:
return f"{base}{pk}/regenerate/"
if pk:
return f"{base}{pk}/"
return base
def _make_webhook(workspace, url="https://example.com/hook"):
return Webhook.objects.create(workspace=workspace, url=url)
@pytest.mark.contract
class TestWebhookSecretKeyScope:
@pytest.mark.django_db
def test_list_does_not_leak_secret_key(self, session_client, workspace):
_make_webhook(workspace)
response = session_client.get(_webhooks_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
payload = response.json()
assert payload, "expected at least one webhook"
assert all("secret_key" not in item for item in payload)
@pytest.mark.django_db
def test_retrieve_does_not_leak_secret_key(self, session_client, workspace):
webhook = _make_webhook(workspace)
response = session_client.get(_webhooks_url(workspace.slug, pk=webhook.id))
assert response.status_code == status.HTTP_200_OK
assert "secret_key" not in response.json()
@pytest.mark.django_db
def test_patch_does_not_leak_secret_key(self, session_client, workspace):
webhook = _make_webhook(workspace)
# Patch a non-url field so URL SSRF validation is not exercised.
response = session_client.patch(
_webhooks_url(workspace.slug, pk=webhook.id), {"is_active": False}, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert "secret_key" not in response.json()
assert response.json()["is_active"] is False
@pytest.mark.django_db
def test_regenerate_reveals_secret_key(self, session_client, workspace):
webhook = _make_webhook(workspace)
old_secret = webhook.secret_key
response = session_client.post(_webhooks_url(workspace.slug, pk=webhook.id, regenerate=True))
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert "secret_key" in body
assert body["secret_key"] and body["secret_key"] != old_secret
@pytest.mark.django_db
def test_create_reveals_secret_key(self, session_client, workspace, monkeypatch):
# Bypass only the network boundary (DNS/IP SSRF resolution); the domain and
# schema checks in _validate_webhook_url still run for example.com, and the
# test survives a rename of the private method.
monkeypatch.setattr("plane.app.serializers.webhook.validate_url", lambda *a, **k: None)
response = session_client.post(
_webhooks_url(workspace.slug), {"url": "https://example.com/hook"}, format="json"
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json().get("secret_key")
@pytest.mark.django_db
def test_serializer_hides_secret_by_default_and_reveals_with_flag(self, workspace):
webhook = _make_webhook(workspace)
assert "secret_key" not in WebhookSerializer(webhook).data
# An explicit fields= request must not re-open the leak either: enforcement
# lives in to_representation, not the (currently no-op) DynamicBaseSerializer
# field allowlist. Pins the enforcement point so a future _filter_fields fix
# can't silently re-expose the key.
assert "secret_key" not in WebhookSerializer(webhook, fields=("secret_key",)).data
revealed = WebhookSerializer(webhook, context={"show_secret_key": True}).data
assert revealed["secret_key"] == webhook.secret_key

View File

@@ -0,0 +1,77 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from django.urls import reverse
from rest_framework import status
from unittest.mock import patch
from plane.db.models import Workspace, WorkspaceMember
@pytest.mark.contract
class TestWorkspaceAPI:
"""Test workspace CRUD operations"""
@pytest.mark.django_db
def test_create_workspace_empty_data(self, session_client):
"""Test creating a workspace with empty data"""
url = reverse("workspace")
# Test with empty data
response = session_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user):
"""Test creating a workspace with valid data"""
url = reverse("workspace")
user = create_user # Use the create_user fixture directly as it returns a user object
# Test with valid data - include all required fields
workspace_data = {
"name": "Plane",
"slug": "pla-ne-test",
"company_name": "Plane Inc.",
}
# Make the request
response = session_client.post(url, workspace_data, format="json")
# Check response status
assert response.status_code == status.HTTP_201_CREATED
# Verify workspace was created
assert Workspace.objects.count() == 1
# Check if the member is created
assert WorkspaceMember.objects.count() == 1
# Check other values
workspace = Workspace.objects.get(slug=workspace_data["slug"])
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user).first()
assert workspace.owner == user
assert workspace_member.role == 20
# Verify the workspace_seed task was called
mock_workspace_seed.assert_called_once_with(response.data["id"])
@pytest.mark.django_db
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
def test_create_duplicate_workspace(self, mock_workspace_seed, session_client):
"""Test creating a duplicate workspace"""
url = reverse("workspace")
# Create first workspace
session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json")
# Try to create a workspace with the same slug
response = session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json")
# The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Optionally check the error message to confirm it's related to the duplicate slug
assert "slug" in response.data

View File

@@ -0,0 +1,122 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for workspace-wide cycles/modules project-scoping.
Regression coverage for GHSA-wcc5-qgfr-8g9c (WEB-8068). ``WorkspaceCyclesEndpoint``
and ``WorkspaceModulesEndpoint`` are guarded only by ``WorkspaceViewerPermission``
(any active workspace member) and previously filtered by ``workspace__slug`` alone.
That let any workspace member enumerate cycle/module metadata (names, dates, issue
counts) for private projects they were not a member of.
The fix restricts both querysets to projects the requesting user is an active
member of, mirroring ``WorkspaceStatesEndpoint`` / ``WorkspaceLabelsEndpoint``.
"""
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
Cycle,
Module,
Project,
ProjectMember,
User,
WorkspaceMember,
)
CYCLES_URL = "/api/workspaces/{slug}/cycles/"
MODULES_URL = "/api/workspaces/{slug}/modules/"
@pytest.fixture
def project(db, workspace, create_user):
"""A project in the fixture workspace; ``create_user`` is an active member."""
project = Project.objects.create(
name="Private Project",
identifier="PP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
return project
@pytest.fixture
def cycle(db, workspace, project, create_user):
return Cycle.objects.create(
name="Private Cycle",
project=project,
workspace=workspace,
owned_by=create_user,
)
@pytest.fixture
def module(db, workspace, project):
return Module.objects.create(
name="Private Module",
project=project,
workspace=workspace,
)
@pytest.fixture
def outsider_client(db, workspace):
"""Session client for a workspace member who is NOT in ``project``."""
unique_id = uuid4().hex[:8]
outsider = User.objects.create(
email=f"outsider-{unique_id}@plane.so",
username=f"outsider_{unique_id}",
first_name="Outsider",
last_name="User",
)
outsider.set_password("test-password")
outsider.save()
WorkspaceMember.objects.create(workspace=workspace, member=outsider, role=15)
client = APIClient()
client.force_authenticate(user=outsider)
return client
@pytest.mark.contract
class TestWorkspaceCyclesModulesProjectScope:
"""A workspace member must not see cycles/modules of projects they aren't in."""
@pytest.mark.django_db
def test_cycles_hidden_from_non_project_member(self, outsider_client, workspace, cycle):
response = outsider_client.get(CYCLES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert response.data == [], f"Leaked cycle metadata: {response.data!r}"
@pytest.mark.django_db
def test_modules_hidden_from_non_project_member(self, outsider_client, workspace, module):
response = outsider_client.get(MODULES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert response.data == [], f"Leaked module metadata: {response.data!r}"
@pytest.mark.django_db
def test_cycles_visible_to_project_member(self, session_client, workspace, cycle):
"""Positive control: an active project member still sees the cycle."""
response = session_client.get(CYCLES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(row["id"]) for row in response.data}
assert str(cycle.id) in ids, f"Expected cycle {cycle.id} in {response.data!r}"
@pytest.mark.django_db
def test_modules_visible_to_project_member(self, session_client, workspace, module):
"""Positive control: an active project member still sees the module."""
response = session_client.get(MODULES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(row["id"]) for row in response.data}
assert str(module.id) in ids, f"Expected module {module.id} in {response.data!r}"

View File

@@ -0,0 +1,209 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for ``WorkspaceFileAssetEndpoint`` project-scoping.
Regression coverage for GHSA-h7mc-p9mm-2r4w / GHSA-cjph-cgm5-8pw8 (WEB-8066),
an incomplete fix of the GHSA-qw87 asset-IDOR cluster.
The endpoint is authorized at the WORKSPACE level, so any workspace member or
guest previously reached ``get``/``patch``/``delete`` for a project-bound asset
(issue attachment / description, comment description, page description) even
when they were not a member of that asset's project. The fix requires an active
``ProjectMember`` of ``asset.project_id`` for project-bound assets, while
leaving workspace-level assets (WORKSPACE_LOGO, USER_AVATAR, USER_COVER, whose
``project_id`` is NULL) accessible to any workspace member.
"""
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
FileAsset,
Project,
ProjectMember,
User,
WorkspaceMember,
)
S3_STORAGE_PATH = "plane.app.views.asset.v2.S3Storage"
@pytest.fixture
def project(db, workspace, create_user):
"""A project in the fixture workspace; ``create_user`` is an active member."""
project = Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
return project
@pytest.fixture
def outsider_user(db):
"""A user who is a workspace member but NOT a member of ``project``."""
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"outsider-{unique_id}@plane.so",
username=f"outsider_{unique_id}",
first_name="Outsider",
last_name="User",
)
user.set_password("test-password")
user.save()
return user
@pytest.fixture
def outsider_client(db, workspace, outsider_user):
"""Session client for a workspace member who is not in ``project``."""
WorkspaceMember.objects.create(
workspace=workspace, member=outsider_user, role=15
)
client = APIClient()
client.force_authenticate(user=outsider_user)
return client
@pytest.fixture
def project_asset(db, workspace, project, create_user):
"""An uploaded issue attachment that belongs to ``project``."""
return FileAsset.objects.create(
attributes={"name": "secret.pdf", "type": "application/pdf", "size": 1024},
asset=f"{workspace.id}/secret.pdf",
size=1024,
workspace=workspace,
project=project,
created_by=create_user,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
storage_metadata={"size": 1024},
)
@pytest.fixture
def workspace_logo_asset(db, workspace, create_user):
"""A workspace-level asset (project_id is NULL) — exempt from project scope."""
return FileAsset.objects.create(
attributes={"name": "logo.png", "type": "image/png", "size": 256},
asset=f"{workspace.id}/logo.png",
size=256,
workspace=workspace,
created_by=create_user,
entity_type=FileAsset.EntityTypeContext.WORKSPACE_LOGO,
is_uploaded=True,
storage_metadata={"size": 256},
)
def detail_url(slug, asset_id):
return f"/api/assets/v2/workspaces/{slug}/{asset_id}/"
@pytest.mark.contract
class TestWorkspaceFileAssetProjectScope:
"""A workspace member who is not in the asset's project must be blocked."""
@pytest.mark.django_db
def test_get_project_asset_denied_for_non_project_member(
self, outsider_client, workspace, project_asset
):
"""GET on a project asset by a non-project-member must 403, not mint a
presigned download URL."""
url = detail_url(workspace.slug, project_asset.id)
with mock.patch(S3_STORAGE_PATH) as mock_storage:
mock_storage.return_value.generate_presigned_url.return_value = (
"https://signed.example/download"
)
response = outsider_client.get(url)
assert response.status_code == status.HTTP_403_FORBIDDEN, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
mock_storage.return_value.generate_presigned_url.assert_not_called()
@pytest.mark.django_db
def test_patch_project_asset_denied_for_non_project_member(
self, outsider_client, workspace, project_asset
):
"""PATCH on a project asset by a non-project-member must 403 and leave
the asset untouched."""
url = detail_url(workspace.slug, project_asset.id)
project_asset.is_uploaded = False
project_asset.save(update_fields=["is_uploaded"])
response = outsider_client.patch(
url, {"attributes": {"name": "hacked.pdf"}}, format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
project_asset.refresh_from_db()
assert project_asset.is_uploaded is False
assert project_asset.attributes.get("name") == "secret.pdf"
@pytest.mark.django_db
def test_delete_project_asset_denied_for_non_project_member(
self, outsider_client, workspace, project_asset
):
"""DELETE on a project asset by a non-project-member must 403 and must
not soft-delete the asset."""
url = detail_url(workspace.slug, project_asset.id)
response = outsider_client.delete(url)
assert response.status_code == status.HTTP_403_FORBIDDEN, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
project_asset.refresh_from_db()
assert project_asset.is_deleted is False
@pytest.mark.django_db
def test_get_project_asset_allowed_for_project_member(
self, session_client, workspace, project_asset
):
"""Positive control: an active project member can still download the
asset, so the fix does not over-block legitimate callers."""
url = detail_url(workspace.slug, project_asset.id)
with mock.patch(S3_STORAGE_PATH) as mock_storage:
mock_storage.return_value.generate_presigned_url.return_value = (
"https://signed.example/download"
)
response = session_client.get(url)
assert response.status_code == status.HTTP_302_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
mock_storage.return_value.generate_presigned_url.assert_called_once()
@pytest.mark.django_db
def test_get_workspace_level_asset_allowed_for_non_project_member(
self, outsider_client, workspace, workspace_logo_asset
):
"""Exemption control: a workspace-level asset (project_id NULL) stays
accessible to any workspace member."""
url = detail_url(workspace.slug, workspace_logo_asset.id)
with mock.patch(S3_STORAGE_PATH) as mock_storage:
mock_storage.return_value.generate_presigned_url.return_value = (
"https://signed.example/download"
)
response = outsider_client.get(url)
assert response.status_code == status.HTTP_302_FOUND, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
mock_storage.return_value.generate_presigned_url.assert_called_once()

View File

@@ -0,0 +1,83 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from plane.db.models import User, WorkspaceMember
from plane.db.models.workspace import WorkspaceUserPreference
@pytest.mark.contract
class TestWorkspaceUserPreferencePatch:
"""Contract tests for the sidebar preference PATCH endpoint.
Regression coverage for #9260: ``patch`` filtered ``WorkspaceUserPreference``
by ``key``/``workspace__slug`` only, so in a workspace with multiple members
``.first()`` (ordered by ``-created_at``) could return — and mutate — another
member's preference row instead of the requesting user's.
"""
KEY = WorkspaceUserPreference.UserPreferenceKeys.ANALYTICS.value
@pytest.mark.django_db
def test_patch_only_updates_requesting_users_preference(self, session_client, create_user, workspace):
"""A member's PATCH must update only their own preference, never another member's."""
# A second, more-recently-active member of the same workspace.
other_user = User.objects.create(
email="other@plane.so", username="other_user", first_name="Other", last_name="User"
)
WorkspaceMember.objects.create(workspace=workspace, member=other_user, role=15)
own_pref = WorkspaceUserPreference.objects.create(
workspace=workspace, user=create_user, key=self.KEY, is_pinned=False, sort_order=100
)
other_pref = WorkspaceUserPreference.objects.create(
workspace=workspace, user=other_user, key=self.KEY, is_pinned=False, sort_order=200
)
# Force the other member's row to sort first under the model's ``-created_at``
# ordering, so an unscoped ``.first()`` would deterministically pick it.
now = timezone.now()
WorkspaceUserPreference.objects.filter(pk=own_pref.pk).update(created_at=now)
WorkspaceUserPreference.objects.filter(pk=other_pref.pk).update(created_at=now + timedelta(minutes=1))
url = reverse("workspace-user-preference", kwargs={"slug": workspace.slug})
response = session_client.patch(
url, [{"key": self.KEY, "is_pinned": True, "sort_order": 999}], format="json"
)
assert response.status_code == status.HTTP_200_OK
own_pref.refresh_from_db()
other_pref.refresh_from_db()
# The requesting user's preference is updated...
assert own_pref.is_pinned is True
assert own_pref.sort_order == 999
# ...and the other member's preference is left untouched.
assert other_pref.is_pinned is False
assert other_pref.sort_order == 200
@pytest.mark.django_db
def test_patch_updates_own_preference(self, session_client, create_user, workspace):
"""Baseline: a member's PATCH persists changes to their own preference row."""
preference = WorkspaceUserPreference.objects.create(
workspace=workspace, user=create_user, key=self.KEY, is_pinned=False, sort_order=100
)
url = reverse("workspace-user-preference", kwargs={"slug": workspace.slug})
response = session_client.patch(
url, [{"key": self.KEY, "is_pinned": True, "sort_order": 42}], format="json"
)
assert response.status_code == status.HTTP_200_OK
preference.refresh_from_db()
assert preference.is_pinned is True
assert preference.sort_order == 42

View File

@@ -0,0 +1,85 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import factory
from uuid import uuid4
from django.utils import timezone
from plane.db.models import User, Workspace, WorkspaceMember, Project, ProjectMember
class UserFactory(factory.django.DjangoModelFactory):
"""Factory for creating User instances"""
class Meta:
model = User
django_get_or_create = ("email",)
id = factory.LazyFunction(uuid4)
email = factory.Sequence(lambda n: f"user{n}@plane.so")
password = factory.PostGenerationMethodCall("set_password", "password")
first_name = factory.Sequence(lambda n: f"First{n}")
last_name = factory.Sequence(lambda n: f"Last{n}")
is_active = True
is_superuser = False
is_staff = False
class WorkspaceFactory(factory.django.DjangoModelFactory):
"""Factory for creating Workspace instances"""
class Meta:
model = Workspace
django_get_or_create = ("slug",)
id = factory.LazyFunction(uuid4)
name = factory.Sequence(lambda n: f"Workspace {n}")
slug = factory.Sequence(lambda n: f"workspace-{n}")
owner = factory.SubFactory(UserFactory)
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)
class WorkspaceMemberFactory(factory.django.DjangoModelFactory):
"""Factory for creating WorkspaceMember instances"""
class Meta:
model = WorkspaceMember
id = factory.LazyFunction(uuid4)
workspace = factory.SubFactory(WorkspaceFactory)
member = factory.SubFactory(UserFactory)
role = 20 # Admin role by default
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)
class ProjectFactory(factory.django.DjangoModelFactory):
"""Factory for creating Project instances"""
class Meta:
model = Project
django_get_or_create = ("name", "workspace")
id = factory.LazyFunction(uuid4)
name = factory.Sequence(lambda n: f"Project {n}")
workspace = factory.SubFactory(WorkspaceFactory)
created_by = factory.SelfAttribute("workspace.owner")
updated_by = factory.SelfAttribute("workspace.owner")
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)
class ProjectMemberFactory(factory.django.DjangoModelFactory):
"""Factory for creating ProjectMember instances"""
class Meta:
model = ProjectMember
id = factory.LazyFunction(uuid4)
project = factory.SubFactory(ProjectFactory)
member = factory.SubFactory(UserFactory)
role = 20 # Admin role by default
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,101 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
import requests
from django.urls import reverse
@pytest.mark.smoke
class TestAuthSmoke:
"""Smoke tests for authentication endpoints"""
@pytest.mark.django_db
def test_login_endpoint_available(self, plane_server, create_user, user_data):
"""Test that the login endpoint is available and responds correctly"""
# Get the sign-in URL
relative_url = reverse("sign-in")
url = f"{plane_server.url}{relative_url}"
# 1. Test bad login - test with wrong password
response = requests.post(url, data={"email": user_data["email"], "password": "wrong-password"})
# For bad credentials, any of these status codes would be valid
# The test shouldn't be brittle to minor implementation changes
assert response.status_code != 500, "Authentication should not cause server errors"
assert response.status_code != 404, "Authentication endpoint should exist"
if response.status_code == 200:
# If API returns 200 for failures, check the response body for error indication
if hasattr(response, "json"):
try:
data = response.json()
# JSON response might indicate error in its structure
assert (
"error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in")
), "Error response should contain error details"
except ValueError:
# It's ok if response isn't JSON format
pass
elif response.status_code in [302, 303]:
# If it's a redirect, it should redirect to a login page or error page
redirect_url = response.headers.get("Location", "")
assert "error" in redirect_url or "sign-in" in redirect_url, (
"Failed login should redirect to login page or error page"
)
# 2. Test good login with correct credentials
response = requests.post(
url,
data={"email": user_data["email"], "password": user_data["password"]},
allow_redirects=False, # Don't follow redirects
)
# Successful auth should not be a client error or server error
assert response.status_code not in range(400, 600), (
f"Authentication with valid credentials failed with status {response.status_code}"
)
# Specific validation based on response type
if response.status_code in [302, 303]:
# Redirect-based auth: check that redirect URL doesn't contain error
redirect_url = response.headers.get("Location", "")
assert "error" not in redirect_url and "error_code" not in redirect_url, (
"Successful login redirect should not contain error parameters"
)
elif response.status_code == 200:
# API token-based auth: check for tokens or user session
if hasattr(response, "json"):
try:
data = response.json()
# If it's a token response
if "access_token" in data:
assert "refresh_token" in data, "JWT auth should return both access and refresh tokens"
# If it's a user session response
elif "user" in data:
assert "is_authenticated" in data and data["is_authenticated"], (
"User session response should indicate authentication"
)
# Otherwise it should at least indicate success
else:
assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), (
"Success response should not contain error keys"
)
except ValueError:
# Non-JSON is acceptable if it's a redirect or HTML response
pass
@pytest.mark.smoke
class TestHealthCheckSmoke:
"""Smoke test for health check endpoint"""
def test_healthcheck_endpoint(self, plane_server):
"""Test that the health check endpoint is available and responds correctly"""
# Make a request to the health check endpoint
response = requests.get(f"{plane_server.url}/")
# Should be OK
assert response.status_code == 200, "Health check endpoint should return 200 OK"

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,143 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit tests for the log cleanup tasks.
Verifies that API activity logs past the retention window are hard-deleted
(removed from PostgreSQL, not soft-deleted) and that fresh logs are retained.
"""
from datetime import timedelta
import pytest
from django.conf import settings
from django.utils import timezone
from uuid import uuid4
from plane.bgtasks.cleanup_task import (
delete_api_logs,
delete_email_notification_logs,
delete_webhook_logs,
process_cleanup_task,
)
from plane.db.models import APIActivityLog, EmailNotificationLog, WebhookLog
from plane.tests.factories import UserFactory, WorkspaceFactory
def _make_api_log(created_at):
log = APIActivityLog.objects.create(
token_identifier="hashed-token",
path="/api/v1/workspaces/",
method="GET",
response_code=200,
)
# created_at is auto-set on insert, so backdate it explicitly afterwards.
APIActivityLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
return log
def _make_webhook_log(workspace, created_at):
log = WebhookLog.objects.create(
workspace=workspace,
webhook=uuid4(),
event_type="issue",
request_method="POST",
response_status="200",
)
WebhookLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
return log
def _make_email_log(user, sent_at):
return EmailNotificationLog.objects.create(
receiver=user,
triggered_by=user,
entity_name="issue",
entity="issue",
sent_at=sent_at,
)
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteApiLogs:
def test_expired_logs_are_hard_deleted(self):
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
expired = _make_api_log(timezone.now() - timedelta(days=retention_days + 1))
delete_api_logs()
# Hard delete: the row must be gone even from the unfiltered manager.
assert not APIActivityLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
recent = _make_api_log(timezone.now() - timedelta(days=retention_days - 1))
delete_api_logs()
assert APIActivityLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteWebhookLogs:
def test_expired_logs_are_hard_deleted(self):
workspace = WorkspaceFactory()
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
expired = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days + 1))
delete_webhook_logs()
assert not WebhookLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
workspace = WorkspaceFactory()
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
recent = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days - 1))
delete_webhook_logs()
assert WebhookLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteEmailLogs:
def test_expired_logs_are_hard_deleted(self):
user = UserFactory()
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
expired = _make_email_log(user, timezone.now() - timedelta(days=retention_days + 1))
delete_email_notification_logs()
assert not EmailNotificationLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
user = UserFactory()
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
recent = _make_email_log(user, timezone.now() - timedelta(days=retention_days - 1))
delete_email_notification_logs()
assert EmailNotificationLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
class TestProcessCleanupTaskErrorHandling:
def test_batch_delete_failure_is_swallowed(self):
"""A failing batch is logged and skipped; the run does not raise."""
class _BoomManager:
@staticmethod
def filter(**kwargs):
raise RuntimeError("db unavailable")
class _BoomModel:
all_objects = _BoomManager()
# Should not raise even though the delete blows up.
process_cleanup_task(lambda: iter([1, 2, 3]), _BoomModel, "Boom")

View File

@@ -0,0 +1,175 @@
# 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 plane.db.models import Project, ProjectMember, Issue, FileAsset
from unittest.mock import patch, MagicMock
from plane.bgtasks.copy_s3_object import (
copy_s3_objects_of_description_and_assets,
copy_assets,
)
import base64
@pytest.mark.unit
class TestCopyS3Objects:
"""Test the copy_s3_objects_of_description_and_assets function"""
@pytest.fixture
def project(self, create_user, workspace):
project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user)
return project
@pytest.fixture
def issue(self, workspace, project):
return Issue.objects.create(
name="Test Issue",
workspace=workspace,
project_id=project.id,
description_html='<div><image-component src="35e8b958-6ee5-43ce-ae56-fb0e776f421e"></image-component><image-component src="97988198-274f-4dfe-aa7a-4c0ffc684214"></image-component></div>', # noqa: E501
)
@pytest.fixture
def file_asset(self, workspace, project, issue):
return FileAsset.objects.create(
issue=issue,
workspace=workspace,
project=project,
asset="workspace1/test-asset-1.jpg",
attributes={
"name": "test-asset-1.jpg",
"size": 100,
"type": "image/jpeg",
},
id="35e8b958-6ee5-43ce-ae56-fb0e776f421e",
entity_type="ISSUE_DESCRIPTION",
)
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_s3_objects_of_description_and_assets(
self, mock_s3_storage, create_user, workspace, project, issue, file_asset
):
FileAsset.objects.create(
issue=issue,
workspace=workspace,
project=project,
asset="workspace1/test-asset-2.pdf",
attributes={
"name": "test-asset-2.pdf",
"size": 100,
"type": "application/pdf",
},
id="97988198-274f-4dfe-aa7a-4c0ffc684214",
entity_type="ISSUE_DESCRIPTION",
)
issue.save()
# Set up mock S3 storage
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Mock the external service call to avoid actual HTTP requests
with patch("plane.bgtasks.copy_s3_object.sync_with_external_service") as mock_sync:
mock_sync.return_value = {
"description": "test description",
"description_binary": base64.b64encode(b"test binary").decode(),
"description_json": {"type": "doc", "content": []},
}
# Call the actual function (not .delay())
copy_s3_objects_of_description_and_assets("ISSUE", issue.id, project.id, "test-workspace", create_user.id)
# Assert that copy_object was called for each asset
assert mock_storage_instance.copy_object.call_count == 2
# Get the updated issue and its new assets
updated_issue = Issue.objects.get(id=issue.id)
new_assets = FileAsset.objects.filter(
issue=updated_issue,
entity_type="ISSUE_DESCRIPTION",
)
# Verify new assets were created
assert new_assets.count() == 4 # 2 original + 2 copied
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_successful(self, mock_s3_storage, workspace, project, issue, file_asset):
"""Test successful copying of assets"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[file_asset.id],
user_id=issue.created_by_id,
)
# Assert
# Verify S3 copy was called
mock_storage_instance.copy_object.assert_called_once()
# Verify new asset was created
assert len(result) == 1
new_asset_id = result[0]["new_asset_id"]
new_asset = FileAsset.objects.get(id=new_asset_id)
# Verify asset properties were copied correctly
assert new_asset.workspace == workspace
assert new_asset.project_id == project.id
assert new_asset.entity_type == file_asset.entity_type
assert new_asset.attributes == file_asset.attributes
assert new_asset.size == file_asset.size
assert new_asset.is_uploaded is True
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_empty_asset_ids(self, mock_s3_storage, workspace, project, issue):
"""Test copying with empty asset_ids list"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[],
user_id=issue.created_by_id,
)
# Assert
assert result == []
mock_storage_instance.copy_object.assert_not_called()
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_nonexistent_asset(self, mock_s3_storage, workspace, project, issue):
"""Test copying with non-existent asset ID"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
non_existent_id = "00000000-0000-0000-0000-000000000000"
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[non_existent_id],
user_id=issue.created_by_id,
)
# Assert
assert result == []
mock_storage_instance.copy_object.assert_not_called()

View File

@@ -0,0 +1,289 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Per-advisory SSRF regression tests.
Each test reproduces a published / reported SSRF advisory scenario and asserts
the current code blocks it. This file is the auditable map of "which advisory is
covered where"; the lower-level mechanics (IP classification, pinning, redirect
re-validation) are exercised in detail in ``test_url_security.py`` and
``test_work_item_link_task.py``.
Advisory coverage
-----------------
Webhook delivery
* GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx
webhook URL resolves to a private/metadata/loopback IP -> TestWebhookUrlValidation
* GHSA-75fg-f8qg-23wv CGNAT(100.64/10), 6to4, multicast missed -> TestWebhookUrlValidation
* GHSA-6485-m23r-fx8q PATCH serializer context-key bypass -> TestWebhookPatchContextGuard
* GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 /
-9292-pvg4-7hvm / -fgcv-6h3f-xcx9 webhook DNS-rebinding TOCTOU -> TestWebhookRebinding
* GHSA-6v37-328w-j2wv / -jw6g-h7h5-rfc6 / -mq87-52pf-hm3h
webhook SSRF via HTTP redirect following -> TestWebhookRedirect
Work-item link unfurling / favicon
* GHSA-8wvv-p676-hcw4 / -fv24-3845-646g / -9292-pvg4-7hvm link rebinding
* GHSA-9fr2-pprw-pp9j / CVE-2026-39843 favicon redirect SSRF -> TestFaviconRedirect
* GHSA-3856-6mgg-rx84 favicon DNS-rebinding -> TestFaviconRebinding
OAuth avatar (the still-unresolved family this change adds)
* GHSA-cv9p-325g-wmv5 OAuth avatar redirect SSRF -> static-asset exfil
* GHSA-hx79-5pj5-qh42 Gitea OAuth SSRF (avatar hop) -> TestOAuthAvatarSSRF
"""
import pytest
import requests
from unittest.mock import MagicMock, patch
from bs4 import BeautifulSoup
from plane.utils.ip_address import validate_url
from plane.bgtasks.work_item_link_task import fetch_and_encode_favicon, DEFAULT_FAVICON
from plane.authentication.adapter.base import Adapter
def _addr(ip):
family = 6 if ":" in ip else 2
return (family, None, None, None, (ip, 0))
def _resp(status_code=200, headers=None, content=b"OK"):
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.headers = headers or {}
resp.content = content
return resp
_BLOCKED = "Access to private/internal networks is not allowed"
# ---------------------------------------------------------------------------
# Webhook URL validation (creation/update-time defense in depth)
# GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx / GHSA-75fg-f8qg-23wv
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookUrlValidation:
@pytest.mark.parametrize(
"ip",
[
"169.254.169.254", # AWS/GCP metadata (CVE-2026-30242 PoC)
"127.0.0.1", # loopback
"10.0.0.1", # private
"172.16.0.1", # private
"192.168.0.1", # private
"::1", # IPv6 loopback
"100.64.0.1", # CGNAT / RFC 6598 (GHSA-75fg)
"2002:7f00:1::", # 6to4 -> 127.0.0.1 (GHSA-75fg)
"224.0.0.1", # multicast (GHSA-75fg)
"::ffff:169.254.169.254", # IPv4-mapped metadata
],
)
def test_webhook_url_to_internal_is_rejected(self, ip):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr(ip)]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"https://attacker.example.com/hook",
allowed_ips=[],
allowed_hosts=[],
)
def test_legitimate_public_webhook_url_passes(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
# Should not raise
validate_url("https://hooks.example.com/x", allowed_ips=[], allowed_hosts=[])
# ---------------------------------------------------------------------------
# GHSA-6485-m23r-fx8q — PATCH serializer context-key bypass
# The PATCH view now passes context={"request": request}; with the request in
# context the disallowed-domain / request-host loop-back guard runs on update.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookPatchContextGuard:
def _serializer_with_request(self, host):
from plane.app.serializers import WebhookSerializer
request = MagicMock()
request.get_host.return_value = host
return WebhookSerializer(context={"request": request})
def test_request_host_is_blocked_when_context_present(self):
# A webhook pointed at the instance's own host must be rejected — this
# is the guard the PATCH endpoint silently skipped with the wrong key.
ser = self._serializer_with_request("myplane.example.com")
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")] # public, so only the host guard can block
with pytest.raises(Exception, match="not allowed"):
ser._validate_webhook_url("https://myplane.example.com/hook")
def test_unrelated_public_host_passes_with_context(self):
ser = self._serializer_with_request("myplane.example.com")
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
ser._validate_webhook_url("https://hooks.partner.com/x") # should not raise
# ---------------------------------------------------------------------------
# Webhook DNS-rebinding TOCTOU
# GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 / -9292 / -fgcv
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookRebinding:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_connection_pinned_to_validated_ip(self, mock_resolve, mock_session_cls):
from plane.utils.url_security import pinned_fetch
# The validator resolves to a public IP; the connection must go to THAT
# IP literal, so a rebind to an internal IP after validation is moot.
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
_, url = session.request.call_args.args
assert url == "https://93.184.216.34:443/hook" # IP literal -> no 2nd DNS lookup
@patch("plane.utils.url_security.resolve_and_validate")
def test_rebind_to_internal_is_blocked(self, mock_resolve):
from plane.utils.url_security import pinned_fetch
mock_resolve.side_effect = ValueError(_BLOCKED)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
# ---------------------------------------------------------------------------
# Webhook SSRF via HTTP redirect following
# GHSA-6v37-328w-j2wv / GHSA-jw6g-h7h5-rfc6 / GHSA-mq87-52pf-hm3h
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookRedirect:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_webhook_does_not_follow_redirects(self, mock_resolve, mock_session_cls):
from plane.utils.url_security import pinned_fetch
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
# The endpoint replies 302 -> internal; the webhook client must NOT follow.
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
)
resp = pinned_fetch("POST", "https://hooks.example.com/x", json={})
# The 3xx is returned as-is and only ONE request was made (no follow).
assert resp.status_code == 302
assert session.request.call_count == 1
assert session.request.call_args.kwargs["allow_redirects"] is False
# ---------------------------------------------------------------------------
# Favicon redirect SSRF — GHSA-9fr2-pprw-pp9j / CVE-2026-39843
# A <link rel=icon> whose href is public but 30x-redirects to a private IP must
# NOT exfiltrate internal content; the favicon falls back to the default icon.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFaviconRedirect:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
def test_favicon_redirect_to_private_returns_default(
self, mock_pre_dns, mock_resolve, mock_session_cls
):
# validate_url_ip pre-check (work_item_link_task.socket) sees a public IP.
mock_pre_dns.return_value = [_addr("93.184.216.34")]
# safe_get: hop0 public, hop1 (redirect target) blocked.
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://192.168.8.14:8081/"}
)
soup = BeautifulSoup(
'<link rel="icon" href="https://redirector.example.com/x">',
"html.parser",
)
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
# Blocked -> default icon, NOT the internal response body.
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
# ---------------------------------------------------------------------------
# Favicon DNS rebinding — GHSA-3856-6mgg-rx84
# The favicon host passes the pre-check (public) but resolves to a private IP at
# fetch time; the pinned client re-resolves+validates and blocks it.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFaviconRebinding:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
def test_favicon_rebind_to_private_returns_default(
self, mock_pre_dns, mock_resolve, mock_session_cls
):
mock_pre_dns.return_value = [_addr("93.184.216.34")] # pre-check: public
mock_resolve.side_effect = ValueError(_BLOCKED) # fetch-time: rebound -> blocked
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
soup = BeautifulSoup(
'<link rel="icon" href="http://rebind.example.com:8443/">',
"html.parser",
)
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
# ---------------------------------------------------------------------------
# OAuth avatar SSRF — GHSA-cv9p-325g-wmv5 / GHSA-hx79-5pj5-qh42 (avatar hop)
# download_and_upload_avatar must reject avatar URLs that point at, or redirect
# to, internal addresses, returning None (no fetch stored as an asset).
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOAuthAvatarSSRF:
def _adapter(self):
return Adapter(request=MagicMock(), provider="gitea")
@patch("plane.utils.url_security.resolve_and_validate")
def test_avatar_to_internal_ip_is_blocked(self, mock_resolve):
mock_resolve.side_effect = ValueError(_BLOCKED)
result = self._adapter().download_and_upload_avatar(
"http://169.254.169.254/latest/meta-data/", user=MagicMock()
)
assert result is None
mock_resolve.assert_called() # SSRF validation was actually attempted
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_avatar_redirect_to_internal_is_blocked(self, mock_resolve, mock_session_cls):
# Public avatar URL that 302-redirects to the metadata service.
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/imds"}
)
result = self._adapter().download_and_upload_avatar(
"https://evil.example.com/avatar", user=MagicMock()
)
assert result is None
@patch("plane.authentication.adapter.base.pinned_fetch_following_redirects")
def test_avatar_uses_ssrf_safe_client(self, mock_fetch):
# Wiring guard: the avatar path must go through the pinned client, never
# a raw requests.get (which would re-resolve + follow redirects freely).
mock_fetch.side_effect = ValueError(_BLOCKED)
result = self._adapter().download_and_upload_avatar(
"https://cdn.example.com/a.png", user=MagicMock()
)
assert result is None
assert mock_fetch.call_args.args[0] == "GET"
assert mock_fetch.call_args.args[1] == "https://cdn.example.com/a.png"

View File

@@ -0,0 +1,395 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
SSRF-protection tests for the webhook + link-unfurling clusters (advisories A/B/C):
A — incomplete private-IP validation -> is_blocked_ip hardening
B — DNS-rebinding TOCTOU -> connection pinned to the validated IP
C — SSRF via HTTP redirect following -> redirects re-resolved/re-validated/re-pinned
"""
import ipaddress
import pytest
import requests
from unittest.mock import MagicMock, patch
from plane.utils.ip_address import is_blocked_ip, resolve_and_validate, validate_url
from plane.utils.url_security import (
PinnedIPAdapter,
pinned_fetch,
pinned_fetch_following_redirects,
)
def _addr(ip):
"""Build a single getaddrinfo-style result tuple for an IP string."""
family = 6 if ":" in ip else 2
return (family, None, None, None, (ip, 0))
def _resp(status_code=200, headers=None, content=b"OK"):
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.headers = headers or {}
resp.content = content
return resp
# ---------------------------------------------------------------------------
# Cluster A — robust IP classification (verified on Python 3.12 semantics)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestIsBlockedIp:
@pytest.mark.parametrize(
"ip",
[
"127.0.0.1", # loopback
"10.0.0.1", # private
"192.168.1.1", # private
"172.16.0.1", # private
"169.254.169.254", # link-local / cloud metadata
"0.0.0.0", # unspecified
"100.64.0.1", # CGNAT / shared (NOT is_private on py3.12!)
"224.0.0.1", # multicast
"239.255.255.250", # SSDP multicast
"255.255.255.255", # limited broadcast
"::1", # IPv6 loopback
"fe80::1", # IPv6 link-local
"fc00::1", # IPv6 unique-local
"ff02::1", # IPv6 multicast
"::ffff:127.0.0.1", # IPv4-mapped loopback
"::ffff:169.254.169.254", # IPv4-mapped metadata
"::ffff:10.0.0.1", # IPv4-mapped private
"64:ff9b::7f00:1", # NAT64 well-known prefix embedding 127.0.0.1
"64:ff9b::a9fe:a9fe", # NAT64 well-known prefix embedding 169.254.169.254
"64:ff9b:1::7f00:1", # NAT64 local-use prefix (RFC 8215, /48)
"64:ff9b:1:0100::1", # NAT64 local-use prefix, outside the /96 subset
"2002:7f00:1::", # 6to4 embedding 127.0.0.1
"2002:a00:1::", # 6to4 embedding 10.0.0.1
],
)
def test_blocks_internal(self, ip):
assert is_blocked_ip(ipaddress.ip_address(ip)) is True
@pytest.mark.parametrize(
"ip",
[
"8.8.8.8",
"93.184.216.34",
"1.1.1.1",
"2606:4700:4700::1111", # public IPv6 (Cloudflare)
"2001:4860:4860::8888", # public IPv6 (Google)
],
)
def test_allows_public(self, ip):
assert is_blocked_ip(ipaddress.ip_address(ip)) is False
# ---------------------------------------------------------------------------
# resolve_and_validate — resolution + validation, returns IPs to pin
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestResolveAndValidate:
def test_returns_public_ips(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
assert resolve_and_validate("example.com") == ["93.184.216.34"]
def test_raises_on_private(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("10.0.0.1")]
with pytest.raises(ValueError, match="private/internal"):
resolve_and_validate("internal.example.com")
def test_raises_if_any_resolved_ip_is_private(self):
# A hostname that resolves to BOTH a public and a private IP must fail
# closed — an attacker could otherwise steer the connection to the
# private one.
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34"), _addr("127.0.0.1")]
with pytest.raises(ValueError, match="private/internal"):
resolve_and_validate("rebinder.example.com")
def test_allowlist_permits_private(self):
allowed = [ipaddress.ip_network("10.0.0.0/8")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("10.0.0.5")]
assert resolve_and_validate("internal", allowed_ips=allowed) == ["10.0.0.5"]
def test_unresolvable_raises(self):
import socket as _socket
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.side_effect = _socket.gaierror()
with pytest.raises(ValueError, match="could not be resolved"):
resolve_and_validate("nope.invalid")
# ---------------------------------------------------------------------------
# Cluster B — connection pinned to the validated IP (DNS-rebinding TOCTOU)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedFetch:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_connects_to_validated_ip_not_hostname(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("POST", "https://example.com/hook", json={"a": 1})
# The socket target is the validated IP literal — there is no second
# DNS lookup, so a rebind between validation and connection is
# impossible.
method, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert method == "POST"
assert url == "https://93.184.216.34:443/hook"
# Host header + TLS SNI still target the real hostname.
assert kwargs["headers"]["Host"] == "example.com"
assert kwargs["allow_redirects"] is False
assert kwargs["verify"] is True
assert kwargs["json"] == {"a": 1}
# Ambient proxy/env must not be honoured (would bypass pinning).
assert session.trust_env is False
assert kwargs["proxies"] == {"http": None, "https": None}
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_non_default_port_in_host_header(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "http://example.com:8080/x")
_, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert url == "http://93.184.216.34:8080/x"
assert kwargs["headers"]["Host"] == "example.com:8080"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_ipv6_validated_ip_is_bracketed(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["2606:4700:4700::1111"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://example.com/x")
_, url = session.request.call_args.args
assert url == "https://[2606:4700:4700::1111]:443/x"
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocked_target_raises_before_any_request(self, mock_resolve):
mock_resolve.side_effect = ValueError(
"Access to private/internal networks is not allowed"
)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch("POST", "https://attacker.com/hook")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_tries_next_ip_on_connection_error(self, mock_resolve, mock_session_cls):
# Dual-stack host: first validated IP is unreachable, second works.
mock_resolve.return_value = ["93.184.216.34", "93.184.216.35"]
session = mock_session_cls.return_value
session.request.side_effect = [
requests.ConnectionError("down"),
_resp(200),
]
resp = pinned_fetch("GET", "https://example.com/x")
assert resp.status_code == 200
assert session.request.call_count == 2
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_allowed_host_skips_block_check_but_still_pins(self, mock_resolve, mock_session_cls):
# Trusted host (e.g. internal docker service) whose IP is private: the
# block check is skipped, but the connection is STILL pinned to the
# resolved IP so it cannot be rebound to a different internal target.
mock_resolve.return_value = ["172.18.0.5"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch(
"POST",
"http://silo:3000/hook",
allowed_hosts=["silo"],
json={"x": 1},
)
# Resolution happens with require_safe=False (trusted, skip block check).
assert mock_resolve.call_args.kwargs.get("require_safe") is False
# ...but the connection is pinned to the resolved IP literal, Host=silo.
_, url = session.request.call_args.args
assert url == "http://172.18.0.5:3000/hook"
assert session.request.call_args.kwargs["headers"]["Host"] == "silo:3000"
assert session.request.call_args.kwargs["allow_redirects"] is False
# ---------------------------------------------------------------------------
# Cluster C — redirects re-resolved / re-validated / re-pinned each hop
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedFetchRedirects:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_no_redirect_returns_response(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
assert resp.status_code == 200
assert final == "https://example.com/a"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_follows_and_revalidates_each_hop(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_resp(301, headers={"Location": "https://other.com/page"}),
_resp(200),
]
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
assert resp.status_code == 200
assert final == "https://other.com/page"
# Re-resolved (and thus re-validated + re-pinned) on each hop.
assert mock_resolve.call_count == 2
assert mock_resolve.call_args_list[0].args[0] == "example.com"
assert mock_resolve.call_args_list[1].args[0] == "other.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
# First hop resolves public; redirect target resolves private -> blocked
mock_resolve.side_effect = [
["93.184.216.34"],
ValueError("Access to private/internal networks is not allowed"),
]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch_following_redirects("GET", "https://evil.com/r")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_too_many_redirects(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "https://example.com/loop"}
)
with pytest.raises(requests.TooManyRedirects):
pinned_fetch_following_redirects(
"GET", "https://example.com/start", max_redirects=3
)
# ---------------------------------------------------------------------------
# PinnedIPAdapter — TLS server_hostname injection (cert verified vs hostname)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedIPAdapter:
def test_injects_server_hostname_into_pool(self):
adapter = PinnedIPAdapter(server_hostname="example.com")
adapter.build_connection_pool_key_attributes = MagicMock(
return_value=({"scheme": "https", "host": "93.184.216.34", "port": 443}, {})
)
adapter.poolmanager = MagicMock()
request = MagicMock()
adapter.get_connection_with_tls_context(request, verify=True)
_, kwargs = adapter.poolmanager.connection_from_host.call_args
assert kwargs["pool_kwargs"]["server_hostname"] == "example.com"
# ---------------------------------------------------------------------------
# validate_url — create/update-time defense in depth still rejects bypasses
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateUrlHardening:
@pytest.mark.parametrize("ip", ["100.64.0.1", "224.0.0.1", "0.0.0.0"])
def test_rejects_newly_covered_ranges(self, ip):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr(ip)]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://attacker.example.com")
# ---------------------------------------------------------------------------
# Review-feedback fixes (PR #9163)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestReviewFixes:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_url_embedded_credentials_become_basic_auth(self, mock_resolve, mock_session_cls):
# user:pass@host -> Basic Auth preserved as auth=, userinfo stripped from URL
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://user:p%40ss@example.com/hook")
_, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert url == "https://93.184.216.34:443/hook" # no userinfo in the IP URL
assert kwargs["auth"] == ("user", "p@ss") # percent-decoded
assert kwargs["headers"]["Host"] == "example.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_no_credentials_passes_auth_none(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://example.com/x")
assert session.request.call_args.kwargs["auth"] is None
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_ipv6_literal_host_header_is_bracketed(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["2606:4700:4700::1111"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://[2606:4700:4700::1111]/x")
kwargs = session.request.call_args.kwargs
assert kwargs["headers"]["Host"] == "[2606:4700:4700::1111]"
def test_idna_unicode_error_is_treated_as_unresolvable(self):
# getaddrinfo can raise UnicodeError (IDNA) before any lookup; it must
# surface as ValueError so webhook_send_task records a URL rejection.
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.side_effect = UnicodeError("label empty or too long")
with pytest.raises(ValueError, match="could not be resolved"):
resolve_and_validate("xn--bad-name")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_stream_defers_session_close_until_response_close(self, mock_resolve, mock_session_cls):
# With stream=True the size cap can bound memory only if the session
# stays open until the body is read; closing the response closes it.
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
resp = _resp(200)
session.request.return_value = resp
out = pinned_fetch("GET", "https://cdn.example.com/a.png", stream=True)
assert session.request.call_args.kwargs["stream"] is True
session.close.assert_not_called() # deferred
out.close()
session.close.assert_called_once()

View File

@@ -0,0 +1,230 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import ipaddress
import pytest
import requests
from unittest.mock import patch, MagicMock
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
from plane.utils.ip_address import validate_url
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
"""Create a mock requests.Response."""
resp = MagicMock()
resp.status_code = status_code
resp.is_redirect = is_redirect
resp.headers = headers or {}
resp.content = content
return resp
@pytest.mark.unit
class TestValidateUrlIp:
"""Test validate_url_ip blocks private/internal IPs."""
def test_rejects_private_ip(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://example.com")
def test_rejects_loopback(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://example.com")
def test_rejects_non_http_scheme(self):
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
validate_url_ip("file:///etc/passwd")
def test_allows_public_ip(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
validate_url_ip("https://example.com") # Should not raise
@pytest.mark.parametrize(
"ip",
[
"100.64.0.1", # CGNAT / shared address space (not is_private on 3.12)
"224.0.0.1", # multicast
"0.0.0.0", # unspecified
"::ffff:169.254.169.254", # IPv4-mapped cloud metadata
"64:ff9b::a9fe:a9fe", # NAT64 embedding 169.254.169.254
],
)
def test_rejects_hardened_bypass_ranges(self, ip):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, (ip, 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://attacker.example.com")
@pytest.mark.unit
class TestValidateUrlAllowlist:
"""Test validate_url allowlist permits specific private IPs."""
def test_allowlist_permits_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_does_not_permit_other_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowlist_permits_loopback_when_explicitly_allowed(self):
allowed = [ipaddress.ip_network("127.0.0.0/8")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowed_hosts_bypasses_private_ip_check(self):
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
trusted internal services (e.g. Silo) whose IPs are dynamic in
containerised deployments."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
def test_allowed_hosts_matches_case_insensitively(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
validate_url(
"http://Silo.Namespace.Svc.Cluster.Local/x",
allowed_hosts=["silo.namespace.svc.cluster.local"],
) # Should not raise
def test_allowed_hosts_skips_dns_lookup(self):
"""When the hostname is explicitly trusted we shouldn't even resolve it —
protects against operators who allowlist a name that isn't resolvable
from the API container."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
validate_url("http://silo/hook", allowed_hosts=["silo"])
mock_dns.assert_not_called()
def test_allowed_hosts_requires_exact_match(self):
"""Subdomains of an allowed host must NOT bypass — a hostile
``attacker.silo.internal`` should still be blocked when only
``silo.internal`` is allowed."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"http://attacker.silo.internal/x",
allowed_hosts=["silo.internal"],
)
def test_allowed_hosts_empty_does_not_bypass(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://silo/hook", allowed_hosts=[])
@pytest.mark.unit
class TestSafeGet:
"""safe_get now delegates to the pinned SSRF-safe client; assert it resolves,
validates, pins to the validated IP and follows redirects safely. Network is
mocked at the requests.Session boundary inside plane.utils.url_security."""
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_returns_response_for_non_redirect(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _make_response(status_code=200, content=b"OK")
response, final_url = safe_get("https://example.com")
assert response.status_code == 200
assert final_url == "https://example.com"
# Pinned to the validated IP literal, not the hostname.
_, url = session.request.call_args.args
assert url == "https://93.184.216.34:443/"
assert session.request.call_args.kwargs["headers"]["Host"] == "example.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_follows_redirect_and_validates_each_hop(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_make_response(status_code=301, headers={"Location": "https://other.com/page"}),
_make_response(status_code=200, content=b"OK"),
]
response, final_url = safe_get("https://example.com")
assert response.status_code == 200
assert final_url == "https://other.com/page"
assert mock_resolve.call_count == 2
assert mock_resolve.call_args_list[0].args[0] == "example.com"
assert mock_resolve.call_args_list[1].args[0] == "other.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
mock_resolve.side_effect = [
["93.184.216.34"],
ValueError("Access to private/internal networks is not allowed"),
]
session = mock_session_cls.return_value
session.request.return_value = _make_response(
status_code=302, headers={"Location": "http://192.168.1.1:8080"}
)
with pytest.raises(ValueError, match="private/internal"):
safe_get("https://evil.com/redirect")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_raises_on_too_many_redirects(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _make_response(
status_code=302, headers={"Location": "https://example.com/loop"}
)
with pytest.raises(requests.TooManyRedirects):
safe_get("https://example.com/start")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_succeeds_at_exact_max_redirects(self, mock_resolve, mock_session_cls):
"""5 redirects then a 200 must succeed (MAX_REDIRECTS == 5)."""
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_make_response(status_code=302, headers={"Location": "https://example.com/next"})
] * 5 + [_make_response(status_code=200, content=b"OK")]
response, final_url = safe_get("https://example.com/start")
assert response.status_code == 200

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,46 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit tests for APIKeyAuthentication.
Covers the access-control guarantees of the external API key authentication:
- a valid token belonging to an active user authenticates successfully
- a valid token is rejected once the underlying user account is deactivated
(prevents an authentication bypass via a disabled account that still holds
a previously generated API key)
"""
import pytest
from rest_framework.exceptions import AuthenticationFailed
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.db.models import APIToken
@pytest.mark.unit
class TestAPIKeyAuthentication:
@pytest.mark.django_db
def test_validate_api_token_authenticates_active_user(self, create_user):
token = APIToken.objects.create(
user=create_user, label="Active Token", token="active-user-token"
)
user, returned_token = APIKeyAuthentication().validate_api_token(token.token)
assert user == create_user
assert returned_token == token.token
@pytest.mark.django_db
def test_validate_api_token_rejects_deactivated_user(self, create_user):
token = APIToken.objects.create(
user=create_user, label="Stale Token", token="deactivated-user-token"
)
# Account is deactivated by an administrator after the token was issued.
create_user.is_active = False
create_user.save()
with pytest.raises(AuthenticationFailed):
APIKeyAuthentication().validate_api_token(token.token)

View File

@@ -0,0 +1,423 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit tests for ReadReplicaRoutingMiddleware.
This module contains comprehensive tests for the ReadReplicaRoutingMiddleware
that handles intelligent database routing to read replicas based on HTTP methods
and view configuration.
Test Organization:
- TestReadReplicaRoutingMiddleware: Core middleware functionality
- TestProcessView: process_view method behavior
- TestReplicaDecisionLogic: Decision logic for replica usage
- TestAttributeDetection: View attribute detection methods
- TestExceptionHandling: Exception handling and cleanup
- TestRealViewIntegration: Real Django/DRF view integration
- TestEdgeCases: Edge cases and error conditions
"""
import pytest
from unittest.mock import Mock, patch
from django.http import HttpResponse
from django.test import RequestFactory
from django.views import View
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from plane.middleware.db_routing import ReadReplicaRoutingMiddleware
# Pytest fixtures
@pytest.fixture
def mock_get_response():
"""Fixture for mocked get_response callable."""
return Mock(return_value=HttpResponse())
@pytest.fixture
def middleware(mock_get_response):
"""Fixture for ReadReplicaRoutingMiddleware instance."""
return ReadReplicaRoutingMiddleware(mock_get_response)
@pytest.fixture
def request_factory():
"""Fixture for Django RequestFactory."""
return RequestFactory()
@pytest.fixture
def mock_view_func():
"""Fixture for a basic mocked view function."""
view = Mock()
view.use_read_replica = True
return view
@pytest.fixture
def get_request(request_factory):
"""Fixture for a GET request."""
return request_factory.get("/api/test/")
@pytest.fixture
def post_request(request_factory):
"""Fixture for a POST request."""
return request_factory.post("/api/test/")
@pytest.mark.unit
class TestReadReplicaRoutingMiddleware:
"""Test cases for ReadReplicaRoutingMiddleware core functionality."""
def test_middleware_initialization(self, middleware, mock_get_response):
"""Test middleware initializes correctly with expected attributes."""
assert middleware.get_response == mock_get_response
assert hasattr(middleware, "READ_ONLY_METHODS")
assert "GET" in middleware.READ_ONLY_METHODS
assert "HEAD" in middleware.READ_ONLY_METHODS
assert "OPTIONS" in middleware.READ_ONLY_METHODS
def test_read_only_methods_constant(self, middleware):
"""Test READ_ONLY_METHODS contains expected HTTP methods."""
expected_methods = {"GET", "HEAD", "OPTIONS"}
assert middleware.READ_ONLY_METHODS == expected_methods
@patch("plane.middleware.db_routing.set_use_read_replica")
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_routes_write_methods_to_primary(
self, mock_clear, mock_set, middleware, post_request, mock_get_response
):
"""Test __call__ routes write methods to primary database."""
response = middleware(post_request)
mock_set.assert_called_once_with(False) # Primary database
mock_clear.assert_called_once()
assert response == mock_get_response.return_value
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_with_read_methods_waits_for_process_view(
self, mock_clear, middleware, get_request, mock_get_response
):
"""Test __call__ with read methods waits for process_view."""
response = middleware(get_request)
mock_clear.assert_called_once()
assert response == mock_get_response.return_value
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_always_cleans_up_context(self, mock_clear, middleware, get_request):
"""Test __call__ always cleans up context."""
middleware(get_request)
mock_clear.assert_called_once()
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_call_cleans_up_context_on_exception(self, mock_clear, middleware, get_request, mock_get_response):
"""Test __call__ cleans up context even if get_response raises."""
mock_get_response.side_effect = Exception("Test exception")
with pytest.raises(Exception, match="Test exception"):
middleware(get_request)
mock_clear.assert_called_once()
@pytest.mark.unit
class TestProcessView:
"""Test cases for process_view method functionality."""
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_read_method_and_replica_true(self, mock_set, middleware, get_request):
"""Test process_view with GET request and use_read_replica=True."""
view_func = Mock()
view_func.use_read_replica = True
result = middleware.process_view(get_request, view_func, (), {})
mock_set.assert_called_once_with(True)
assert result is None
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_read_method_and_replica_false(self, mock_set, middleware, get_request):
"""Test process_view with GET request and use_read_replica=False."""
view_func = Mock()
view_func.use_read_replica = False
result = middleware.process_view(get_request, view_func, (), {})
mock_set.assert_called_once_with(False)
assert result is None
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_read_method_and_no_replica_attribute(self, mock_set, middleware, get_request):
"""Test process_view with GET request and no use_read_replica attr."""
view_func = Mock(spec=[]) # No use_read_replica attribute
result = middleware.process_view(get_request, view_func, (), {})
mock_set.assert_called_once_with(False) # Default to primary
assert result is None
def test_with_write_method_ignores_view_attributes(self, middleware, post_request):
"""Test process_view with write methods ignores view attributes."""
view_func = Mock()
view_func.use_read_replica = True # This should be ignored for POST
result = middleware.process_view(post_request, view_func, (), {})
assert result is None # Should not process for write methods
@pytest.mark.unit
class TestReplicaDecisionLogic:
"""Test cases for replica decision logic methods."""
def test_should_use_read_replica_with_true_attribute(self, middleware):
"""Test _should_use_read_replica returns True for True attribute."""
view_func = Mock()
view_func.use_read_replica = True
result = middleware._should_use_read_replica(view_func)
assert result is True
def test_should_use_read_replica_with_false_attribute(self, middleware):
"""Test _should_use_read_replica returns False for False attribute."""
view_func = Mock()
view_func.use_read_replica = False
result = middleware._should_use_read_replica(view_func)
assert result is False
def test_should_use_read_replica_with_no_attribute_defaults_false(self, middleware):
"""Test _should_use_read_replica defaults to False for missing attr."""
view_func = Mock(spec=[]) # No use_read_replica attribute
result = middleware._should_use_read_replica(view_func)
assert result is False
@pytest.mark.unit
class TestAttributeDetection:
"""Test cases for view attribute detection methods."""
def test_get_use_replica_attribute_function_based_view(self, middleware):
"""Test _get_use_replica_attribute with function-based view."""
# Test with True
view_func = Mock()
view_func.use_read_replica = True
result = middleware._get_use_replica_attribute(view_func)
assert result is True
# Test with False
view_func.use_read_replica = False
result = middleware._get_use_replica_attribute(view_func)
assert result is False
# Test with no attribute
view_func = Mock(spec=[])
result = middleware._get_use_replica_attribute(view_func)
assert result is None
def test_get_use_replica_attribute_django_cbv(self, middleware):
"""Test _get_use_replica_attribute with Django CBV wrapper."""
view_class = Mock()
view_class.use_read_replica = True
view_func = Mock()
view_func.view_class = view_class
# Remove use_read_replica from view_func to ensure it checks view_class
del view_func.use_read_replica
result = middleware._get_use_replica_attribute(view_func)
assert result is True
def test_get_use_replica_attribute_drf_wrapper(self, middleware):
"""Test _get_use_replica_attribute with DRF wrapper."""
# Create a real object to avoid Mock issues
class ViewClass:
use_read_replica = True
class ViewFunc:
cls = ViewClass()
view_func = ViewFunc()
result = middleware._get_use_replica_attribute(view_func)
assert result is True
def test_get_use_replica_attribute_priority_order(self, middleware):
"""Test attribute priority: direct > view_class > cls."""
view_func = Mock()
view_func.use_read_replica = True # Direct attribute (highest priority)
# Add conflicting attributes with lower priority
view_class = Mock()
view_class.use_read_replica = False
view_func.view_class = view_class
cls = Mock()
cls.use_read_replica = False
view_func.cls = cls
result = middleware._get_use_replica_attribute(view_func)
assert result is True # Should use direct attribute
@pytest.mark.parametrize(
"value,expected",
[
(True, True),
(False, False),
(1, True),
(0, False),
("yes", True),
("", False),
([], False),
([1], True),
(None, False),
],
)
def test_should_use_read_replica_truthy_falsy_values(self, middleware, value, expected):
"""Test _should_use_read_replica with various truthy/falsy values."""
# Create a real object to test the attribute handling
class TestView:
pass
view_func = TestView()
view_func.use_read_replica = value
result = middleware._should_use_read_replica(view_func)
assert result == expected
@pytest.mark.unit
class TestExceptionHandling:
"""Test cases for exception handling and cleanup."""
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_process_exception_cleans_up_context(self, mock_clear, middleware, request_factory):
"""Test process_exception cleans up context."""
request = request_factory.get("/api/test/")
exception = Exception("Test exception")
result = middleware.process_exception(request, exception)
mock_clear.assert_called_once()
assert result is None # Don't handle the exception
@patch("plane.middleware.db_routing.set_use_read_replica")
@patch("plane.middleware.db_routing.clear_read_replica_context")
def test_integration_full_request_cycle(self, mock_clear, mock_set, middleware, request_factory, mock_get_response):
"""Test complete request cycle from __call__ through process_view."""
request = request_factory.get("/api/test/")
view_func = Mock()
view_func.use_read_replica = True
# Call middleware and process_view manually
response = middleware(request)
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
mock_clear.assert_called_once()
assert response == mock_get_response.return_value
@pytest.mark.unit
class TestRealViewIntegration:
"""Test middleware with real Django/DRF view classes."""
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_django_class_based_view(self, mock_set, middleware, request_factory):
"""Test middleware with actual Django CBV."""
class TestView(View):
use_read_replica = True
# Simulate Django's URL resolver creating a view wrapper
view_func = TestView.as_view()
request = request_factory.get("/api/test/")
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_drf_api_view(self, mock_set, middleware, request_factory):
"""Test middleware with DRF APIView."""
class TestAPIView(APIView):
use_read_replica = True
# Simulate DRF's URL pattern creating a view wrapper
view_func = TestAPIView.as_view()
request = request_factory.get("/api/test/")
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
@patch("plane.middleware.db_routing.set_use_read_replica")
def test_with_drf_viewset(self, mock_set, middleware, request_factory):
"""Test middleware with DRF ViewSet."""
class TestViewSet(ViewSet):
use_read_replica = True
# Simulate DRF router creating viewset action
view_func = TestViewSet.as_view({"get": "list"})
request = request_factory.get("/api/test/")
middleware.process_view(request, view_func, (), {})
mock_set.assert_called_once_with(True)
@pytest.mark.unit
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_process_view_with_none_view_func(self, middleware, request_factory):
"""Test process_view handles None view_func gracefully."""
request = request_factory.get("/api/test/")
result = middleware.process_view(request, None, (), {})
assert result is None # Should not crash
def test_get_use_replica_attribute_with_attribute_error(self, middleware):
"""Test _get_use_replica_attribute with view that raises AttributeError."""
# Create a view class that raises AttributeError on access
class ProblematicView:
def __getattr__(self, name):
if name == "use_read_replica":
raise AttributeError("Simulated attribute error")
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
view_func = ProblematicView()
result = middleware._get_use_replica_attribute(view_func)
assert result is None # Should handle gracefully
def test_multiple_exception_calls_are_safe(self, middleware, request_factory):
"""Test that multiple calls to process_exception don't cause issues."""
request = request_factory.get("/api/test/")
exception = Exception("Test exception")
# Call multiple times
result1 = middleware.process_exception(request, exception)
result2 = middleware.process_exception(request, exception)
assert result1 is None # Both should return None safely
assert result2 is None

View File

@@ -0,0 +1,79 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit tests for APITokenLogMiddleware.
Covers the credential-hygiene guarantees of the external API request logger:
- the raw API key is never persisted (a non-reversible hash is stored instead)
- sensitive request headers are redacted before being logged
"""
import hashlib
import hmac
from unittest.mock import Mock, patch
import pytest
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponse
from django.test import RequestFactory
from plane.middleware.logger import APITokenLogMiddleware
@pytest.fixture
def request_factory():
return RequestFactory()
@pytest.fixture
def middleware():
return APITokenLogMiddleware(Mock(return_value=HttpResponse(b"{}")))
@pytest.mark.unit
class TestAPITokenLogMiddleware:
API_KEY = "plane_api_supersecretvalue"
AUTHORIZATION = "Bearer secret-bearer-token"
COOKIE = "sessionid=secret-session-value"
def _captured_log_data(self, middleware, request_factory):
request = request_factory.get(
"/api/v1/workspaces/",
HTTP_X_API_KEY=self.API_KEY,
HTTP_AUTHORIZATION=self.AUTHORIZATION,
HTTP_COOKIE=self.COOKIE,
)
request.user = AnonymousUser()
response = HttpResponse(b"{}")
with patch("plane.middleware.logger.process_logs") as process_logs:
middleware.process_request(request, response, request_body=b"")
assert process_logs.delay.called
return process_logs.delay.call_args.kwargs["log_data"]
def test_token_identifier_is_hashed_not_plaintext(self, middleware, request_factory):
log_data = self._captured_log_data(middleware, request_factory)
expected_hash = hmac.new(
settings.SECRET_KEY.encode(), self.API_KEY.encode(), hashlib.sha256
).hexdigest()
assert log_data["token_identifier"] == expected_hash
assert self.API_KEY not in log_data["token_identifier"]
def test_sensitive_headers_are_redacted(self, middleware, request_factory):
log_data = self._captured_log_data(middleware, request_factory)
# None of the sensitive header values may appear in the logged headers.
assert self.API_KEY not in log_data["headers"]
assert self.AUTHORIZATION not in log_data["headers"]
assert self.COOKIE not in log_data["headers"]
assert "[REDACTED]" in log_data["headers"]
def test_no_log_without_api_key(self, middleware, request_factory):
request = request_factory.get("/api/v1/workspaces/")
request.user = AnonymousUser()
with patch("plane.middleware.logger.process_logs") as process_logs:
middleware.process_request(request, HttpResponse(b"{}"), request_body=b"")
assert not process_logs.delay.called

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,290 @@
# 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 plane.db.models import IssueComment, Description, Project, Issue, Workspace, State
@pytest.fixture
def workspace(create_user):
"""Create a test workspace"""
return Workspace.objects.create(
name="Test Workspace",
slug="test-workspace",
owner=create_user,
)
@pytest.fixture
def project(workspace, create_user):
"""Create a test project"""
return Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)
@pytest.fixture
def state(project):
"""Create a test state"""
return State.objects.create(
name="Todo",
project=project,
group="backlog",
default=True,
)
@pytest.fixture
def issue(workspace, project, state, create_user):
"""Create a test issue"""
return Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
state=state,
created_by=create_user,
)
@pytest.mark.unit
class TestIssueCommentModel:
"""Test the IssueComment model"""
@pytest.mark.django_db
def test_issue_comment_creation_creates_description(self, workspace, project, issue, create_user):
"""Test that creating a comment automatically creates a description"""
# Arrange
comment_html = "<p>This is a test comment</p>"
comment_json = {"type": "doc", "content": [{"type": "paragraph", "text": "This is a test comment"}]}
# Act
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=comment_html,
comment_json=comment_json,
created_by=create_user,
updated_by=create_user,
)
# Assert
assert issue_comment.id is not None
assert issue_comment.comment_stripped == "This is a test comment"
assert issue_comment.description_id is not None
# Verify description was created
description = Description.objects.get(pk=issue_comment.description_id)
assert description is not None
assert description.description_html == comment_html
assert description.description_json == comment_json
assert description.description_stripped == "This is a test comment"
assert description.workspace_id == workspace.id
assert description.project_id == project.id
@pytest.mark.django_db
def test_issue_comment_update_updates_description(self, workspace, project, issue, create_user):
"""Test that updating a comment updates its associated description"""
# Arrange - Create initial comment
initial_html = "<p>Initial comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
initial_description_id = issue_comment.description_id
# Act - Update the comment
updated_html = "<p>Updated comment</p>"
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated comment"}]}
issue_comment.comment_html = updated_html
issue_comment.comment_json = updated_json
issue_comment.save()
# Assert
# Refresh from database
issue_comment.refresh_from_db()
updated_description = Description.objects.get(pk=initial_description_id)
# Verify comment was updated
assert issue_comment.comment_stripped == "Updated comment"
assert issue_comment.description_id == initial_description_id # Same description object
# Verify description was updated
assert updated_description.description_html == updated_html
assert updated_description.description_json == updated_json
assert updated_description.description_stripped == "Updated comment"
@pytest.mark.django_db
def test_issue_comment_update_only_changed_fields_in_description(self, workspace, project, issue, create_user):
"""Test that only changed fields are updated in description"""
# Arrange - Create initial comment
initial_html = "<p>Initial comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
initial_description_id = issue_comment.description_id
# Act - Update only the HTML (not JSON)
updated_html = "<p>Updated comment only HTML</p>"
issue_comment.comment_html = updated_html
# comment_json remains the same
issue_comment.save()
# Assert
updated_description = Description.objects.get(pk=initial_description_id)
# Verify HTML was updated
assert updated_description.description_html == updated_html
assert updated_description.description_stripped == "Updated comment only HTML"
# Verify JSON remained the same
assert updated_description.description_json == initial_json
@pytest.mark.django_db
def test_issue_comment_no_update_when_content_unchanged(self, workspace, project, issue, create_user):
"""Test that description is not updated when comment content doesn't change"""
# Arrange - Create initial comment
initial_html = "<p>Test comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Test comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
initial_description_id = issue_comment.description_id
# Act - Save without changing content
issue_comment.save()
# Assert
updated_description = Description.objects.get(pk=initial_description_id)
# Verify description was not updated (updated_at should be the same)
# Note: This test assumes updated_at is not changed when no fields change
assert updated_description.description_html == initial_html
assert updated_description.description_json == initial_json
assert updated_description.description_stripped == "Test comment"
@pytest.mark.django_db
def test_issue_comment_update_creates_description_if_missing(self, workspace, project, issue, create_user):
"""Test that updating a comment creates description if it doesn't exist (legacy data)"""
# Arrange - Create comment and manually remove description (simulating legacy data)
initial_html = "<p>Legacy comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Legacy comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
# Simulate legacy data by removing the description
if issue_comment.description_id:
Description.objects.filter(pk=issue_comment.description_id).delete()
IssueComment.objects.filter(pk=issue_comment.pk).update(description_id=None)
issue_comment.refresh_from_db()
assert issue_comment.description_id is None
# Act - Update the comment
updated_html = "<p>Updated legacy comment</p>"
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated legacy comment"}]}
issue_comment.comment_html = updated_html
issue_comment.comment_json = updated_json
issue_comment.save()
# Assert
issue_comment.refresh_from_db()
# Verify description was created
assert issue_comment.description_id is not None
description = Description.objects.get(pk=issue_comment.description_id)
assert description.description_html == updated_html
assert description.description_json == updated_json
assert description.description_stripped == "Updated legacy comment"
@pytest.mark.django_db
def test_issue_comment_strips_html_tags(self, workspace, project, issue, create_user):
"""Test that HTML tags are properly stripped from comment_html"""
# Arrange
comment_html = "<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
comment_json = {"type": "doc", "content": []}
# Act
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=comment_html,
comment_json=comment_json,
created_by=create_user,
updated_by=create_user,
)
# Assert
assert issue_comment.comment_stripped == "This is bold and italic text"
# Verify description has the same stripped content
description = Description.objects.get(pk=issue_comment.description_id)
assert description.description_stripped == "This is bold and italic text"
@pytest.mark.django_db
def test_issue_comment_empty_html_creates_empty_stripped(self, workspace, project, issue, create_user):
"""Test that empty HTML results in empty comment_stripped"""
# Arrange
comment_html = ""
comment_json = {"type": "doc", "content": []}
# Act
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=comment_html,
comment_json=comment_json,
created_by=create_user,
updated_by=create_user,
)
# Assert
assert issue_comment.comment_stripped == ""
# Verify description was created with empty stripped content
description = Description.objects.get(pk=issue_comment.description_id)
assert description.description_stripped is None

View File

@@ -0,0 +1,48 @@
# 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 uuid import uuid4
from plane.db.models import Workspace, WorkspaceMember
@pytest.mark.unit
class TestWorkspaceModel:
"""Test the Workspace model"""
@pytest.mark.django_db
def test_workspace_creation(self, create_user):
"""Test creating a workspace"""
# Create a workspace
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
)
# Verify it was created
assert workspace.id is not None
assert workspace.name == "Test Workspace"
assert workspace.slug == "test-workspace"
assert workspace.owner == create_user
@pytest.mark.django_db
def test_workspace_member_creation(self, create_user):
"""Test creating a workspace member"""
# Create a workspace
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
)
# Create a workspace member
workspace_member = WorkspaceMember.objects.create(
workspace=workspace,
member=create_user,
role=20, # Admin role
)
# Verify it was created
assert workspace_member.id is not None
assert workspace_member.workspace == workspace
assert workspace_member.member == create_user
assert workspace_member.role == 20

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,73 @@
# 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 plane.db.models import (
Workspace,
Project,
Issue,
User,
IssueAssignee,
WorkspaceMember,
ProjectMember,
)
from plane.app.serializers.workspace import IssueRecentVisitSerializer
from django.utils import timezone
@pytest.mark.unit
class TestIssueRecentVisitSerializer:
"""Test the IssueRecentVisitSerializer"""
def test_issue_recent_visit_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
test_user_1 = User.objects.create(email="test_user_1@example.com", first_name="Test", last_name="User")
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", owner=test_user_1)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]

View File

@@ -0,0 +1,61 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from plane.api.serializers.issue import IssueSerializer
from plane.db.models import Project, ProjectMember, Label, User
@pytest.mark.unit
class TestIssueSerializerAssigneeAndLabelValidation:
"""Test that IssueSerializer rejects invalid assignees/labels instead of silently dropping them"""
@pytest.mark.django_db
def test_rejects_assignee_who_is_not_an_active_project_member(self, db, workspace, create_user):
"""An assignee id that isn't an active project member (role >= 15) must raise a validation error"""
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
outsider = User.objects.create(
email="outsider@example.com", first_name="Out", last_name="Sider", username="outsider"
)
# Not a member of the project at all
serializer = IssueSerializer(
data={"name": "Test Issue", "assignees": [str(outsider.id)]},
context={"project_id": project.id, "workspace_id": workspace.id},
)
assert not serializer.is_valid()
assert "assignees" in str(serializer.errors).lower() or "assignee" in str(serializer.errors).lower()
@pytest.mark.django_db
def test_rejects_label_that_does_not_belong_to_project(self, db, workspace, create_user):
"""A label id that belongs to a different project must raise a validation error"""
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
other_project = Project.objects.create(name="Other Project", identifier="OTHER", workspace=workspace)
foreign_label = Label.objects.create(name="Foreign Label", project=other_project)
serializer = IssueSerializer(
data={"name": "Test Issue", "labels": [str(foreign_label.id)]},
context={"project_id": project.id, "workspace_id": workspace.id},
)
assert not serializer.is_valid()
assert "labels" in str(serializer.errors).lower() or "label" in str(serializer.errors).lower()
@pytest.mark.django_db
def test_accepts_assignee_who_is_an_active_project_member(self, db, workspace, create_user):
"""A valid active project member id should still be accepted"""
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=15, is_active=True)
serializer = IssueSerializer(
data={"name": "Test Issue", "assignees": [str(create_user.id)]},
context={"project_id": project.id, "workspace_id": workspace.id},
)
assert serializer.is_valid(), serializer.errors
assert list(serializer.validated_data["assignees"]) == [create_user.id]

View File

@@ -0,0 +1,41 @@
# 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 plane.app.serializers import LabelSerializer
from plane.db.models import Project, Label
@pytest.mark.unit
class TestLabelSerializer:
"""Test the LabelSerializer"""
@pytest.mark.django_db
def test_label_serializer_create_valid_data(self, db, workspace):
"""Test creating a label with valid data"""
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
serializer = LabelSerializer(
data={"name": "Test Label"},
context={"project_id": project.id},
)
assert serializer.is_valid()
assert serializer.errors == {}
serializer.save(project_id=project.id)
label = Label.objects.all().first()
assert label.name == "Test Label"
assert label.project == project
assert label
@pytest.mark.django_db
def test_label_serializer_create_duplicate_name(self, db, workspace):
"""Test creating a label with a duplicate name"""
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
Label.objects.create(name="Test Label", project=project)
serializer = LabelSerializer(data={"name": "Test Label"}, context={"project_id": project.id})
assert not serializer.is_valid()
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}

View File

@@ -0,0 +1,129 @@
# 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 uuid import uuid4
from rest_framework import serializers
from plane.api.serializers import WorkspaceLiteSerializer
from plane.app.serializers import WorkSpaceSerializer
from plane.license.api.serializers import WorkspaceSerializer as InstanceWorkspaceSerializer
from plane.db.models import Workspace, User
# Names with no letter or digit — must be rejected (issue #9255)
SYMBOL_ONLY_NAMES = ["-_________-", "---", "___", "- - -", " ", " -_ "]
# Names containing at least one letter or digit — must be accepted, including
# international scripts, so the rule does not regress non-Latin workspace names
VALID_NAMES = [
"Acme Corp",
"Acme_Corp-123",
"123",
"a",
"R&D",
"José",
"Müller GmbH",
"日本語",
"株式会社",
"محمد",
]
# Names embedding a URL — must be rejected on both create paths
URL_NAMES = ["https://evil.com", "www.example.com", "example.com"]
@pytest.mark.unit
class TestWorkspaceLiteSerializer:
"""Test the WorkspaceLiteSerializer"""
def test_workspace_lite_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
# Create a user to be the owner
owner = User.objects.create(email="test@example.com", first_name="Test", last_name="User")
# Create a workspace with explicit ID to test serialization
workspace_id = uuid4()
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner)
# Serialize the workspace
serialized_data = WorkspaceLiteSerializer(workspace).data
# Check fields are present and correct
assert "name" in serialized_data
assert "slug" in serialized_data
assert "id" in serialized_data
assert serialized_data["name"] == "Test Workspace"
assert serialized_data["slug"] == "test-workspace"
assert str(serialized_data["id"]) == str(workspace_id)
def test_workspace_lite_serializer_read_only(self, db):
"""Test that the serializer fields are read-only"""
# Create a user to be the owner
owner = User.objects.create(email="test2@example.com", first_name="Test", last_name="User")
# Create a workspace
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner)
# Try to update via serializer
serializer = WorkspaceLiteSerializer(workspace, data={"name": "Updated Name", "slug": "updated-slug"})
# Serializer should be valid (since read-only fields are ignored)
assert serializer.is_valid()
# Save should not update the read-only fields
updated_workspace = serializer.save()
assert updated_workspace.name == "Test Workspace"
assert updated_workspace.slug == "test-workspace"
@pytest.mark.unit
class TestWorkSpaceSerializerNameValidation:
"""validate_name must reject symbol-only workspace names (issue #9255).
Frontend validation is bypassable via a direct API call, so the rule is
enforced server-side on both the create and rename (partial_update) paths,
which share this serializer's field-level validation.
"""
@pytest.mark.parametrize("name", SYMBOL_ONLY_NAMES)
def test_rejects_symbol_only_names(self, name):
serializer = WorkSpaceSerializer()
with pytest.raises(serializers.ValidationError):
serializer.validate_name(name)
@pytest.mark.parametrize("name", VALID_NAMES)
def test_accepts_names_with_alphanumeric(self, name):
serializer = WorkSpaceSerializer()
assert serializer.validate_name(name) == name
@pytest.mark.parametrize("name", URL_NAMES)
def test_rejects_names_containing_urls(self, name):
serializer = WorkSpaceSerializer()
with pytest.raises(serializers.ValidationError):
serializer.validate_name(name)
@pytest.mark.unit
class TestInstanceWorkspaceSerializerNameValidation:
"""The instance/license workspace create path must enforce the same rules
as the app serializer (symbol-only rejection AND URL rejection)."""
@pytest.mark.parametrize("name", SYMBOL_ONLY_NAMES)
def test_rejects_symbol_only_names(self, name):
serializer = InstanceWorkspaceSerializer()
with pytest.raises(serializers.ValidationError):
serializer.validate_name(name)
@pytest.mark.parametrize("name", VALID_NAMES)
def test_accepts_names_with_alphanumeric(self, name):
serializer = InstanceWorkspaceSerializer()
assert serializer.validate_name(name) == name
@pytest.mark.parametrize("name", URL_NAMES)
def test_rejects_names_containing_urls(self, name):
serializer = InstanceWorkspaceSerializer()
with pytest.raises(serializers.ValidationError):
serializer.validate_name(name)

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,34 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Unit tests for the log-retention env parsing helper."""
import pytest
from plane.settings.common import _retention_days
ENV_VAR = "TEST_RETENTION_DAYS"
@pytest.mark.unit
class TestRetentionDays:
def test_uses_default_when_unset(self, monkeypatch):
monkeypatch.delenv(ENV_VAR, raising=False)
assert _retention_days(ENV_VAR, 14) == 14
def test_uses_env_value_when_valid(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "30")
assert _retention_days(ENV_VAR, 14) == 30
def test_zero_is_allowed(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "0")
assert _retention_days(ENV_VAR, 14) == 0
def test_negative_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "-5")
assert _retention_days(ENV_VAR, 14) == 14
def test_unparseable_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "abc")
assert _retention_days(ENV_VAR, 7) == 7

View File

@@ -0,0 +1,206 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import os
from unittest.mock import Mock, patch
import pytest
from plane.settings.storage import S3Storage
@pytest.mark.unit
class TestS3StorageSignedURLExpiration:
"""Test the configurable signed URL expiration in S3Storage"""
@patch.dict(os.environ, {}, clear=True)
@patch("plane.settings.storage.boto3")
def test_default_expiration_without_env_variable(self, mock_boto3):
"""Test that default expiration is 3600 seconds when env variable is not set"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance without SIGNED_URL_EXPIRATION env variable
storage = S3Storage()
# Assert default expiration is 3600
assert storage.signed_url_expiration == 3600
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "30"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_with_env_variable(self, mock_boto3):
"""Test that expiration is read from SIGNED_URL_EXPIRATION env variable"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Assert expiration is 30
assert storage.signed_url_expiration == 30
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "300"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_multiple_values(self, mock_boto3):
"""Test that expiration works with different custom values"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=300
storage = S3Storage()
# Assert expiration is 300
assert storage.signed_url_expiration == 300
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "60",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=60
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with custom expiration (60)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 60
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with custom expiration (30)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 30
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_explicit_expiration_overrides_default(self, mock_boto3):
"""Test that explicit expiration parameter overrides the default"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url with explicit expiration=120
storage.generate_presigned_url("test-object", expiration=120)
# Assert that the boto3 method was called with explicit expiration (120)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 120

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,34 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import csv
from io import StringIO
import pytest
from plane.utils.porters.formatters import CSVFormatter
def _read_rows(content):
return list(csv.reader(StringIO(content)))
@pytest.mark.unit
class TestPorterCSVFormatterSanitization:
"""CSV exports must not emit formula-triggering values, including in header rows."""
def test_data_values_are_sanitized(self):
content = CSVFormatter().encode([{"name": "=1+2"}])
rows = _read_rows(content)
assert rows[1][0] == "'=1+2"
def test_prettified_headers_are_sanitized(self):
content = CSVFormatter().encode([{"=evil_header": "value"}])
rows = _read_rows(content)
assert rows[0][0] == "'=Evil Header"
def test_raw_headers_are_sanitized(self):
content = CSVFormatter(prettify_headers=False).encode([{"=evil": "value"}])
rows = _read_rows(content)
assert rows[0][0] == "'=evil"

View File

@@ -0,0 +1,105 @@
# 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 created_at / updated_at rich-filter lookups (WEB-8477 Bug 5).
``created_at`` and ``updated_at`` are DateTimeFields, but the UI sends a bare calendar
date. With the default ``exact`` / ``range`` lookups django-filter coerced those dates to
midnight, so:
* ``created_at__exact=<date>`` matched only rows stamped exactly 00:00:00 -> always empty.
* ``created_at__range=<a>,<b>`` capped the upper bound at ``b`` 00:00:00 -> silently
dropped every row created during the final day of the range.
Both surfaced as "filter returns no work items". The filters now compare the date
component (``date`` / ``date__range``) so a calendar date means the whole day.
"""
import datetime
import pytest
from plane.db.models import Issue, Project, ProjectMember
from plane.utils.filters.filterset import IssueFilterSet
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Filter Project",
identifier="FP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
return project
def _issue_at(project, create_user, name, created, updated):
"""Create an issue then force created_at/updated_at (both are auto-managed)."""
issue = Issue.objects.create(name=name, project=project, workspace=project.workspace, created_by=create_user)
Issue.objects.filter(pk=issue.pk).update(created_at=created, updated_at=updated)
issue.refresh_from_db()
return issue
@pytest.fixture
def issues(db, project, create_user):
tz = datetime.timezone.utc
return {
# created mid-day, NOT at midnight — the case `exact` used to miss
"midday": _issue_at(
project,
create_user,
"midday",
datetime.datetime(2026, 7, 30, 13, 45, tzinfo=tz),
datetime.datetime(2026, 7, 30, 13, 45, tzinfo=tz),
),
# created on an earlier day, but updated on 2026-07-30
"old": _issue_at(
project,
create_user,
"old",
datetime.datetime(2020, 1, 1, 9, 0, tzinfo=tz),
datetime.datetime(2026, 7, 30, 10, 0, tzinfo=tz),
),
}
def _filter(project, data):
qs = Issue.objects.filter(project=project)
fs = IssueFilterSet(data=data, queryset=qs)
assert fs.is_valid(), fs.errors
return set(fs.qs.values_list("name", flat=True))
@pytest.mark.unit
class TestCreatedAtFilter:
def test_exact_matches_whole_day_not_just_midnight(self, project, issues):
# Previously returned nothing because 13:45 != 00:00:00.
assert _filter(project, {"created_at__exact": "2026-07-30"}) == {"midday"}
def test_exact_excludes_other_days(self, project, issues):
assert _filter(project, {"created_at__exact": "2020-01-01"}) == {"old"}
assert _filter(project, {"created_at__exact": "2019-05-05"}) == set()
def test_range_includes_the_final_day(self, project, issues):
# Previously returned nothing: the upper bound became 2026-07-30 00:00:00.
assert _filter(project, {"created_at__range": "2026-07-28,2026-07-30"}) == {"midday"}
def test_single_day_range(self, project, issues):
assert _filter(project, {"created_at__range": "2026-07-30,2026-07-30"}) == {"midday"}
def test_range_excludes_outside_days(self, project, issues):
assert _filter(project, {"created_at__range": "2019-01-01,2019-12-31"}) == set()
@pytest.mark.unit
class TestUpdatedAtFilter:
def test_exact_uses_updated_at_not_created_at(self, project, issues):
# "old" was created in 2020 but updated on 2026-07-30, so it must be included.
assert _filter(project, {"updated_at__exact": "2026-07-30"}) == {"midday", "old"}
def test_range_includes_the_final_day(self, project, issues):
assert _filter(project, {"updated_at__range": "2026-07-28,2026-07-30"}) == {"midday", "old"}

View File

@@ -0,0 +1,53 @@
# 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 plane.utils.issue_filters import filter_updated_at
@pytest.mark.unit
class TestFilterUpdatedAt:
"""Regression test for the "Updated At" filter bug — the filter was
applying to the `created_at` column instead of `updated_at`, so work
items updated today but created earlier never showed up under
`Updated At -> is -> today`. See issue #9316."""
def test_get_method_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": "2026-06-26"}
filter_updated_at(params, issue_filter, method="GET")
# The filter must key on updated_at, not created_at. A bare date goes through
# date_filter's single-value branch, which appends a lookup suffix
# (updated_at__date__contains), so match on the prefix rather than the exact key.
assert any(key.startswith("updated_at__date") for key in issue_filter)
assert not any("created_at" in key for key in issue_filter)
def test_get_method_with_csv_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": "2026-06-25,2026-06-26"}
filter_updated_at(params, issue_filter, method="GET")
assert all("updated_at" in key for key in issue_filter)
assert not any("created_at" in key for key in issue_filter)
def test_post_method_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": ["2026-06-26"]}
filter_updated_at(params, issue_filter, method="POST")
assert all("updated_at" in key for key in issue_filter)
assert not any("created_at" in key for key in issue_filter)
def test_get_method_with_prefix_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": "2026-06-26"}
filter_updated_at(params, issue_filter, method="GET", prefix="cycle_issue__")
keys = list(issue_filter)
assert any("cycle_issue__updated_at" in k for k in keys)
assert not any("created_at" in k for k in keys)
def test_empty_get_filter_is_noop(self):
issue_filter = {}
params = {"updated_at": ""}
filter_updated_at(params, issue_filter, method="GET")
assert issue_filter == {}

View File

@@ -0,0 +1,132 @@
# 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 order_by injection on the external REST API.
Covers GHSA-p885-6jpg-cr2p: the external-API project list and work-item
list endpoints passed a raw ``order_by`` query parameter to Django's
``.order_by()``. Because Django resolves ``__``-separated relational paths,
an attacker could order by sensitive columns on related tables
(``created_by__password``, ``created_by__token``, ``created_by__email``,
``workspace__owner__password`` ...) to build a blind ordering oracle, or
crash the endpoint (HTTP 500) with an unknown field.
The fix routes both endpoints through ``sanitize_order_by()`` with the
appropriate allowlist. These tests assert that the two allowlists used by
those endpoints neutralise the disclosed attack strings and preserve every
legitimate ordering value.
"""
import pytest
from plane.utils.order_queryset import (
ISSUE_ORDER_BY_ALLOWLIST,
PROJECT_ORDER_BY_ALLOWLIST,
sanitize_order_by,
)
# Relational-traversal payloads from the advisory PoC plus common variants.
INJECTION_PAYLOADS = [
"created_by__password",
"created_by__token",
"created_by__email",
"-created_by__password",
"workspace__owner__password",
"updated_by__password",
"not_a_field",
"id; drop table",
"--created_at", # malformed double-dash prefix
]
@pytest.mark.unit
class TestProjectOrderBySanitization:
"""order_by sanitization for GET /api/v1/workspaces/<slug>/projects/."""
DEFAULT = "sort_order"
@pytest.mark.parametrize("payload", INJECTION_PAYLOADS)
def test_injection_payload_falls_back_to_default(self, payload):
"""Any non-allowlisted / relational value is replaced with the
endpoint's safe default instead of reaching .order_by()."""
assert (
sanitize_order_by(payload, PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)
@pytest.mark.parametrize(
"value",
["created_at", "updated_at", "name", "network", "sort_order"],
)
def test_legitimate_ascending_values_pass_through(self, value):
assert (
sanitize_order_by(value, PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== value
)
@pytest.mark.parametrize(
"value",
["-created_at", "-updated_at", "-name", "-network", "-sort_order"],
)
def test_legitimate_descending_values_pass_through(self, value):
assert (
sanitize_order_by(value, PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== value
)
def test_empty_value_uses_default(self):
assert (
sanitize_order_by("", PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)
@pytest.mark.unit
class TestIssueOrderBySanitization:
"""order_by sanitization for
GET /api/v1/workspaces/<slug>/projects/<project_id>/issues/."""
DEFAULT = "-created_at"
@pytest.mark.parametrize("payload", INJECTION_PAYLOADS)
def test_injection_payload_falls_back_to_default(self, payload):
assert (
sanitize_order_by(payload, ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)
@pytest.mark.parametrize(
"value",
[
"created_at",
"updated_at",
"sequence_id",
"sort_order",
"target_date",
"start_date",
"priority",
"state__name",
"state__group",
"assignees__first_name",
"labels__name",
],
)
def test_legitimate_values_pass_through(self, value):
"""Every value the endpoint's branch logic special-cases must survive
sanitization, otherwise legitimate ordering would silently break."""
assert (
sanitize_order_by(value, ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== value
)
# Descending variant is equally valid.
assert (
sanitize_order_by(f"-{value}", ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== f"-{value}"
)
def test_default_is_preserved_for_missing_param(self):
assert (
sanitize_order_by(None, ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)

View File

@@ -0,0 +1,65 @@
# 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 plane.utils.order_queryset import ISSUE_GROUP_BY_ALLOWLIST, sanitize_order_by
@pytest.mark.unit
class TestIssueGroupByAllowlist:
"""Regression tests for GHSA-wwgj-929g-42cm.
ISSUE_GROUP_BY_ALLOWLIST must contain exactly the field names that
`issue_group_values()` (plane/utils/grouper.py and
plane/space/utils/grouper.py) knows how to resolve safely. Anything
outside this set must never reach the ORM as a raw field name via
GroupedOffsetPaginator/SubGroupedOffsetPaginator.
"""
def test_allowlist_matches_known_safe_group_fields(self):
expected = {
"state_id",
"state__group",
"priority",
"labels__id",
"assignees__id",
"issue_module__module_id",
"cycle_id",
"project_id",
"created_by",
"target_date",
"start_date",
}
assert ISSUE_GROUP_BY_ALLOWLIST == frozenset(expected)
def test_allowlist_rejects_injection_style_fields(self):
# These are exactly the kind of values GHSA-wwgj-929g-42cm's PoC used
# to trigger a 500 / force a blind relational-traversal oracle.
dangerous_values = [
"not_a_field",
"created_by__password",
"workspace__secret_key",
"assignees__password",
"id",
"",
None,
]
for value in dangerous_values:
assert value not in ISSUE_GROUP_BY_ALLOWLIST
@pytest.mark.unit
class TestSanitizeOrderByStillWorks:
"""Sanity check that the pre-existing order_by sanitizer (GHSA-2r95 /
GHSA-w45q) is untouched by this change."""
def test_valid_field_passes_through(self):
allowed = frozenset({"created_at", "priority"})
assert sanitize_order_by("priority", allowed) == "priority"
assert sanitize_order_by("-created_at", allowed) == "-created_at"
def test_invalid_field_falls_back_to_default(self):
allowed = frozenset({"created_at"})
assert sanitize_order_by("not_a_field", allowed, default="-created_at") == "-created_at"

View File

@@ -0,0 +1,123 @@
# 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.test import RequestFactory
from rest_framework.exceptions import ParseError
from rest_framework.request import Request
from plane.utils.paginator import BasePaginator, Cursor, CursorResult
class _StubGroupedPaginator:
"""Stand-in for GroupedOffsetPaginator/SubGroupedOffsetPaginator that
records its constructor kwargs without touching the DB/ORM. The security
property under test lives entirely in BasePaginator.paginate() — by the
time a real paginator class would run a query, the field name has
already been validated (or rejected)."""
def __init__(self, **kwargs):
self.kwargs = kwargs
def get_result(self, limit, cursor):
return CursorResult(
results=[],
next=Cursor(limit, 1, False, False),
prev=Cursor(limit, -1, True, False),
hits=0,
max_hits=0,
)
def process_results(self, results):
return results
def _make_request(**params):
django_request = RequestFactory().get("/fake-url/", data=params)
return Request(django_request)
@pytest.mark.unit
class TestPaginateGroupByValidation:
"""Regression tests for GHSA-wwgj-929g-42cm.
BasePaginator.paginate() is the single chokepoint all group_by/sub_group_by
call sites (public Spaces endpoint + 5 authenticated issue/cycle/module/
workspace endpoints) funnel through. It must reject any field name outside
ISSUE_GROUP_BY_ALLOWLIST with a 400 (ParseError), before the value ever
reaches a real paginator's F()/.values()/.order_by()/Window partition_by.
"""
def test_invalid_group_by_raises_parse_error(self):
request = _make_request(group_by="created_by__password")
with pytest.raises(ParseError):
BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
group_by_field_name="created_by__password",
group_by_fields=[],
count_filter=None,
)
def test_invalid_sub_group_by_raises_parse_error(self):
# A valid group_by paired with an invalid sub_group_by must still be
# rejected — the PoC in the advisory used exactly this combination
# (group_by=state_id&sub_group_by=created_by__password).
request = _make_request(group_by="priority", sub_group_by="workspace__secret_key")
with pytest.raises(ParseError):
BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
group_by_field_name="priority",
group_by_fields=[],
sub_group_by_field_name="workspace__secret_key",
sub_group_by_fields=[],
count_filter=None,
)
def test_unrecognised_field_never_reaches_paginator_constructor(self):
# Belt-and-braces: assert the stub paginator is never even
# instantiated for a rejected field name.
request = _make_request(group_by="not_a_field")
class _ExplodingPaginator:
def __init__(self, **kwargs):
raise AssertionError("paginator_cls must not be constructed for an invalid group_by field")
with pytest.raises(ParseError):
BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_ExplodingPaginator,
group_by_field_name="not_a_field",
group_by_fields=[],
count_filter=None,
)
def test_valid_group_by_and_sub_group_by_pass_through(self):
request = _make_request(group_by="priority", sub_group_by="state_id")
response = BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
group_by_field_name="priority",
group_by_fields=[],
sub_group_by_field_name="state_id",
sub_group_by_fields=[],
count_filter=None,
)
assert response.data["grouped_by"] == "priority"
assert response.data["sub_grouped_by"] == "state_id"
def test_no_group_by_is_unaffected(self):
# Plain (non-grouped) pagination must not be touched by this fix.
request = _make_request()
response = BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
)
assert response.data["grouped_by"] is None

View File

@@ -0,0 +1,265 @@
# 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 plane.utils.url import (
contains_url,
is_valid_url,
normalize_url_path,
)
@pytest.mark.unit
class TestContainsURL:
"""Test the contains_url function"""
def test_contains_url_with_http_protocol(self):
"""Test contains_url with HTTP protocol URLs"""
assert contains_url("Check out http://example.com") is True
assert contains_url("Visit http://google.com/search") is True
assert contains_url("http://localhost:8000") is True
def test_contains_url_with_https_protocol(self):
"""Test contains_url with HTTPS protocol URLs"""
assert contains_url("Check out https://example.com") is True
assert contains_url("Visit https://google.com/search") is True
assert contains_url("https://secure.example.com") is True
def test_contains_url_with_www_prefix(self):
"""Test contains_url with www prefix"""
assert contains_url("Visit www.example.com") is True
assert contains_url("Check www.google.com") is True
assert contains_url("Go to www.test-site.org") is True
def test_contains_url_with_domain_patterns(self):
"""Test contains_url with domain patterns"""
assert contains_url("Visit example.com") is True
assert contains_url("Check google.org") is True
assert contains_url("Go to test-site.co.uk") is True
assert contains_url("Visit sub.domain.com") is True
def test_contains_url_with_ip_addresses(self):
"""Test contains_url with IP addresses"""
assert contains_url("Connect to 192.168.1.1") is True
assert contains_url("Visit 10.0.0.1") is True
assert contains_url("Check 127.0.0.1") is True
assert contains_url("Go to 8.8.8.8") is True
def test_contains_url_case_insensitive(self):
"""Test contains_url is case insensitive"""
assert contains_url("Check HTTP://EXAMPLE.COM") is True
assert contains_url("Visit WWW.GOOGLE.COM") is True
assert contains_url("Go to Https://Test.Com") is True
def test_contains_url_with_no_urls(self):
"""Test contains_url with text that doesn't contain URLs"""
assert contains_url("This is just plain text") is False
assert contains_url("No URLs here!") is False
assert contains_url("com org net") is False # Just TLD words
assert contains_url("192.168") is False # Incomplete IP
assert contains_url("") is False # Empty string
def test_contains_url_edge_cases(self):
"""Test contains_url with edge cases"""
assert contains_url("example.c") is False # TLD too short
assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255)
assert contains_url("just-a-hyphen") is False # No domain
assert contains_url("www.") is False # Incomplete www - needs at least one char after dot
def test_contains_url_length_limit_under_1000(self):
"""Test contains_url with input under 1000 characters containing URLs.
Note: contains_url also truncates each line to 500 chars (ReDoS protection),
so URLs must fall within the first 500 chars of their line.
"""
# Single line under 500 chars with URL at the end
text_with_url = "a" * 470 + " https://example.com" # 490 chars total
assert len(text_with_url) < 1000
assert contains_url(text_with_url) is True
# Multi-line input under 1000 chars total; URL on its own short line
text_multiline = "a" * 480 + "\nhttps://example.com\n" + "b" * 480
assert len(text_multiline) < 1000
assert contains_url(text_multiline) is True
def test_contains_url_length_limit_over_1000(self):
"""Test contains_url with input over 1000 characters returns False"""
# Create a string over 1000 characters with a URL
text_with_url = "a" * 982 + "https://example.com" # 982 + 19 = 1001 chars
assert len(text_with_url) > 1000
assert contains_url(text_with_url) is False
# Test with much longer input
long_text_with_url = "a" * 5000 + " https://example.com"
assert contains_url(long_text_with_url) is False
def test_contains_url_length_limit_exactly_1000(self):
"""Test contains_url with input exactly 1000 characters.
URLs must fall within the first 500 chars of their line (ReDoS protection).
"""
# Test with exactly 1000 characters without URL
text_no_url = "a" * 1000
assert len(text_no_url) == 1000
assert contains_url(text_no_url) is False
# Multi-line totalling exactly 1000 chars; URL on a short line
text_with_url = "a" * 480 + "\nhttps://example.com\n" + "b" * 499 # 480+1+19+1+499 = 1000
assert len(text_with_url) == 1000
assert contains_url(text_with_url) is True
def test_contains_url_line_length_scenarios(self):
"""Test contains_url with realistic line length scenarios"""
# Test with multiline input where total is under 1000 but we test line processing
# Short lines with URL
multiline_short = "Line 1\nLine 2 with https://example.com\nLine 3"
assert contains_url(multiline_short) is True
# Multiple lines under total limit
multiline_text = "a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200
assert len(multiline_text) < 1000
assert contains_url(multiline_text) is True
def test_contains_url_total_length_vs_line_length(self):
"""Test the interaction between total length limit and line processing"""
# Test that total length limit takes precedence
# Even if individual lines would be processed, total > 1000 means immediate False
over_limit_text = "a" * 1001 # No URL, but over total limit
assert contains_url(over_limit_text) is False
# Test that under total limit, line processing works normally.
# URL must be within first 500 chars of its line (ReDoS protection).
under_limit_with_url = "a" * 400 + "https://example.com" # 419 chars total, fits in 500
assert len(under_limit_with_url) < 1000
assert contains_url(under_limit_with_url) is True
def test_contains_url_multiline_mixed_lengths(self):
"""Test contains_url with multiple lines of different lengths"""
# Test realistic multiline scenario under 1000 chars total
multiline_text = (
"Short line\n"
+ "a" * 400
+ "https://example.com\n" # Line with URL
+ "b" * 300 # Another line
)
assert len(multiline_text) < 1000
assert contains_url(multiline_text) is True
# Test multiline without URLs
multiline_no_url = "Short line\n" + "a" * 400 + "\n" + "b" * 300
assert len(multiline_no_url) < 1000
assert contains_url(multiline_no_url) is False
def test_contains_url_edge_cases_with_length_limits(self):
"""Test contains_url edge cases related to length limits"""
# Empty string
assert contains_url("") is False
# Very short string with URL
assert contains_url("http://a.co") is True
# String with newlines and mixed content
mixed_content = "Line 1\nLine 2 with https://example.com\nLine 3"
assert contains_url(mixed_content) is True
# String with many newlines under total limit
many_newlines = "\n" * 500 + "https://example.com"
assert len(many_newlines) < 1000
assert contains_url(many_newlines) is True
@pytest.mark.unit
class TestIsValidURL:
"""Test the is_valid_url function"""
def test_is_valid_url_with_valid_urls(self):
"""Test is_valid_url with valid URLs"""
assert is_valid_url("https://example.com") is True
assert is_valid_url("http://google.com") is True
assert is_valid_url("https://sub.domain.com/path") is True
assert is_valid_url("http://localhost:8000") is True
assert is_valid_url("https://example.com/path?query=1") is True
assert is_valid_url("ftp://files.example.com") is True
def test_is_valid_url_with_invalid_urls(self):
"""Test is_valid_url with invalid URLs"""
assert is_valid_url("not a url") is False
assert is_valid_url("example.com") is False # No scheme
assert is_valid_url("https://") is False # No netloc
assert is_valid_url("") is False # Empty string
assert is_valid_url("://example.com") is False # No scheme
assert is_valid_url("https:/example.com") is False # Malformed
def test_is_valid_url_with_non_string_input(self):
"""Test is_valid_url with non-string input"""
assert is_valid_url(None) is False
assert is_valid_url([]) is False
assert is_valid_url({}) is False
def test_is_valid_url_with_special_schemes(self):
"""Test is_valid_url with special URL schemes"""
assert is_valid_url("ftp://ftp.example.com") is True
assert is_valid_url("mailto:user@example.com") is False
assert is_valid_url("file:///path/to/file") is False
@pytest.mark.unit
class TestNormalizeURLPath:
"""Test the normalize_url_path function"""
def test_normalize_url_path_with_multiple_slashes(self):
"""Test normalize_url_path with multiple consecutive slashes"""
result = normalize_url_path("https://example.com//foo///bar//baz")
assert result == "https://example.com/foo/bar/baz"
def test_normalize_url_path_with_query_and_fragment(self):
"""Test normalize_url_path preserves query and fragment"""
result = normalize_url_path("https://example.com//foo///bar//baz?x=1&y=2#fragment")
assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment"
def test_normalize_url_path_with_no_redundant_slashes(self):
"""Test normalize_url_path with already normalized URL"""
url = "https://example.com/foo/bar/baz?x=1#fragment"
result = normalize_url_path(url)
assert result == url
def test_normalize_url_path_with_root_path(self):
"""Test normalize_url_path with root path"""
result = normalize_url_path("https://example.com//")
assert result == "https://example.com/"
def test_normalize_url_path_with_empty_path(self):
"""Test normalize_url_path with empty path"""
result = normalize_url_path("https://example.com")
assert result == "https://example.com"
def test_normalize_url_path_with_complex_path(self):
"""Test normalize_url_path with complex path structure"""
result = normalize_url_path("https://example.com///api//v1///users//123//profile")
assert result == "https://example.com/api/v1/users/123/profile"
def test_normalize_url_path_with_different_schemes(self):
"""Test normalize_url_path with different URL schemes"""
# HTTP
result = normalize_url_path("http://example.com//path")
assert result == "http://example.com/path"
# FTP
result = normalize_url_path("ftp://ftp.example.com//files//document.txt")
assert result == "ftp://ftp.example.com/files/document.txt"
def test_normalize_url_path_with_port(self):
"""Test normalize_url_path with port number"""
result = normalize_url_path("https://example.com:8080//api//v1")
assert result == "https://example.com:8080/api/v1"
def test_normalize_url_path_edge_cases(self):
"""Test normalize_url_path with edge cases"""
# Many consecutive slashes
result = normalize_url_path("https://example.com///////path")
assert result == "https://example.com/path"
# Mixed single and multiple slashes
result = normalize_url_path("https://example.com/a//b/c///d")
assert result == "https://example.com/a/b/c/d"

View File

@@ -0,0 +1,53 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import uuid
import pytest
from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer
@pytest.mark.unit
class TestUUIDUtils:
"""Test the UUID utilities"""
def test_is_valid_uuid_with_valid_uuid(self):
"""Test is_valid_uuid with a valid UUID"""
# Generate a valid UUID
valid_uuid = str(uuid.uuid4())
assert is_valid_uuid(valid_uuid) is True
def test_is_valid_uuid_with_invalid_uuid(self):
"""Test is_valid_uuid with invalid UUID strings"""
# Test with different invalid formats
assert is_valid_uuid("not-a-uuid") is False
assert is_valid_uuid("123456789") is False
assert is_valid_uuid("") is False
assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1
def test_convert_uuid_to_integer(self):
"""Test convert_uuid_to_integer function"""
# Create a known UUID
test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
# Convert to integer
result = convert_uuid_to_integer(test_uuid)
# Check that the result is an integer
assert isinstance(result, int)
# Ensure consistent results with the same input
assert convert_uuid_to_integer(test_uuid) == result
# Different UUIDs should produce different integers
different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
assert convert_uuid_to_integer(different_uuid) != result
def test_convert_uuid_to_integer_string_input(self):
"""Test convert_uuid_to_integer handles string UUID"""
# Test with a UUID string
test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
test_uuid = uuid.UUID(test_uuid_str)
# Should get the same result whether passing UUID or string
assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str)

View File

@@ -0,0 +1,115 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from io import BytesIO
import pytest
from openpyxl import load_workbook
from plane.utils.exporters.formatters import XLSXFormatter as SchemaXLSXFormatter
from plane.utils.porters.formatters import XLSXFormatter as PorterXLSXFormatter
# Characters that trigger formula evaluation in spreadsheet applications.
# See: https://owasp.org/www-community/attacks/CSV_Injection
FORMULA_TRIGGERS = ["=", "+", "-", "@", "\t", "\r", "\n"]
HYPERLINK_PAYLOAD = '=HYPERLINK("https://example.com/poc","click")'
def _load_cells(xlsx_bytes):
"""Load XLSX bytes and return the active worksheet's cells (formulas not evaluated)."""
wb = load_workbook(filename=BytesIO(xlsx_bytes))
return wb.active
@pytest.mark.unit
class TestPorterXLSXFormatterSanitization:
"""XLSX issue exports must not store user-controlled values as formula cells."""
def test_formula_payload_is_stored_as_text(self):
content = PorterXLSXFormatter().encode([{"name": HYPERLINK_PAYLOAD}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + HYPERLINK_PAYLOAD
@pytest.mark.parametrize("trigger", FORMULA_TRIGGERS)
def test_all_formula_trigger_characters_are_escaped(self, trigger):
payload = trigger + "1+2"
content = PorterXLSXFormatter().encode([{"name": payload}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + payload
def test_safe_string_is_unchanged(self):
content = PorterXLSXFormatter().encode([{"name": "Fix login bug"}])
ws = _load_cells(content)
assert ws.cell(row=2, column=1).value == "Fix login bug"
def test_non_string_values_are_preserved(self):
content = PorterXLSXFormatter().encode([{"estimate": 5}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.value == 5
assert cell.data_type == "n"
def test_list_value_joining_to_formula_is_escaped(self):
content = PorterXLSXFormatter().encode([{"labels": ["=cmd", "bug"]}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'=cmd, bug"
def test_headers_are_sanitized(self):
content = PorterXLSXFormatter().encode([{"=evil_header": "value"}])
ws = _load_cells(content)
header = ws.cell(row=1, column=1)
assert header.data_type != "f"
assert header.value == "'=Evil Header"
class _FakeField:
def __init__(self, label=None):
self.label = label
class _FakeSchema:
_declared_fields = {
"name": _FakeField("Name"),
"estimate": _FakeField("=Estimate"),
}
@pytest.mark.unit
class TestSchemaXLSXFormatterSanitization:
"""Schema-based XLSX exports must not store user-controlled values as formula cells."""
def test_formula_payload_is_stored_as_text(self):
_, content = SchemaXLSXFormatter().format("export", [{"name": HYPERLINK_PAYLOAD, "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + HYPERLINK_PAYLOAD
@pytest.mark.parametrize("trigger", FORMULA_TRIGGERS)
def test_all_formula_trigger_characters_are_escaped(self, trigger):
payload = trigger + "1+2"
_, content = SchemaXLSXFormatter().format("export", [{"name": payload, "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + payload
def test_safe_string_is_unchanged(self):
_, content = SchemaXLSXFormatter().format("export", [{"name": "Fix login bug", "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
assert ws.cell(row=2, column=1).value == "Fix login bug"
def test_headers_are_sanitized(self):
_, content = SchemaXLSXFormatter().format("export", [{"name": "ok", "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
header = ws.cell(row=1, column=2)
assert header.data_type != "f"
assert header.value == "'=Estimate"

View File

@@ -0,0 +1,3 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,69 @@
# 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 ``dispatch()`` exception handling on the shared
``BaseAPIView`` / ``BaseViewSet`` classes.
When ``super().dispatch()`` raises an unhandled exception, ``dispatch()`` must
return the HTTP ``Response`` produced by ``handle_exception()`` -- not the raw
exception object. Returning the exception causes Django's response pipeline to
fail with ``TypeError: 'Exception' object is not a valid HTTP response``.
See: https://github.com/makeplane/plane/issues/9157
"""
import pytest
from unittest.mock import patch
from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from plane.api.views.base import BaseAPIView as ApiBaseAPIView, BaseViewSet as ApiBaseViewSet
from plane.app.views.base import BaseAPIView as AppBaseAPIView, BaseViewSet as AppBaseViewSet
from plane.license.api.views.base import BaseAPIView as LicenseBaseAPIView
from plane.space.views.base import BaseAPIView as SpaceBaseAPIView, BaseViewSet as SpaceBaseViewSet
# Every shared base view that wraps ``super().dispatch()`` in a try/except.
VIEW_CLASSES = [
ApiBaseAPIView,
ApiBaseViewSet,
AppBaseAPIView,
AppBaseViewSet,
LicenseBaseAPIView,
SpaceBaseAPIView,
SpaceBaseViewSet,
]
@pytest.mark.unit
@pytest.mark.parametrize(
"view_class",
VIEW_CLASSES,
ids=lambda c: f"{c.__module__}.{c.__name__}",
)
def test_dispatch_returns_response_when_super_dispatch_raises(view_class):
"""dispatch() must return handle_exception()'s Response, not the exception."""
request = APIRequestFactory().get("/api/test/")
view = view_class()
sentinel = Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
with (
patch("rest_framework.views.APIView.dispatch", side_effect=RuntimeError("boom")),
patch.object(view_class, "handle_exception", return_value=sentinel) as mock_handle,
):
result = view.dispatch(request)
mock_handle.assert_called_once()
assert isinstance(result, Response), (
f"{view_class.__module__}.{view_class.__name__}.dispatch() returned "
f"{type(result).__name__} instead of an HTTP Response"
)
assert result is sentinel

View File

@@ -0,0 +1,212 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit regression tests for IssueLinkViewSet.partial_update.
Verifies that crawl_work_item_link_title is only scheduled when the URL
actually changes, preventing overwriting custom link metadata and unnecessary crawls.
See: https://github.com/makeplane/plane/issues/9674
"""
import uuid
from unittest.mock import MagicMock, patch
import pytest
from rest_framework import status
from rest_framework.parsers import JSONParser
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
from plane.app.views.issue.link import IssueLinkViewSet
from plane.api.views.issue import IssueLinkDetailAPIEndpoint
@pytest.mark.unit
class TestIssueLinkPartialUpdate:
@pytest.fixture(autouse=True)
def setup_view(self):
self.factory = APIRequestFactory()
self.view = IssueLinkViewSet()
self.workspace_slug = "test-workspace"
self.project_id = str(uuid.uuid4())
self.issue_id = str(uuid.uuid4())
self.link_id = str(uuid.uuid4())
self.user_id = str(uuid.uuid4())
def _create_mock_request(self, data):
wsgi_request = self.factory.patch("/api/test/", data=data, format="json")
request = Request(wsgi_request, parsers=[JSONParser()])
request.user = MagicMock()
request.user.id = self.user_id
return request
@patch("plane.app.views.issue.link.base_host", return_value="https://app.plane.so")
@patch("plane.app.views.issue.link.issue_activity")
@patch("plane.app.views.issue.link.crawl_work_item_link_title")
@patch("plane.app.views.issue.link.IssueLinkSerializer")
@patch("plane.app.views.issue.link.IssueLink.objects.get")
def test_partial_update_does_not_recrawl_when_url_unchanged(
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity, mock_base_host
):
"""Updating link title or metadata without changing url must NOT trigger crawler."""
initial_url = "https://github.com/makeplane/plane"
mock_instance = MagicMock()
mock_instance.id = self.link_id
mock_instance.url = initial_url
mock_get_link.return_value = mock_instance
# Mock serializer behavior
mock_serializer_instance = MagicMock()
mock_serializer_instance.is_valid.return_value = True
mock_serializer_instance.data = {
"id": self.link_id,
"title": "Custom Renamed Title",
"url": initial_url,
"metadata": {"custom": "data"},
}
mock_serializer_cls.return_value = mock_serializer_instance
# Mock get_queryset for self.get_queryset().get(id=...)
mock_queryset = MagicMock()
mock_queryset.get.return_value = mock_instance
self.view.get_queryset = MagicMock(return_value=mock_queryset)
request = self._create_mock_request({"title": "Custom Renamed Title"})
response = self.view.partial_update(
request,
slug=self.workspace_slug,
project_id=self.project_id,
issue_id=self.issue_id,
pk=self.link_id,
)
assert response.status_code == status.HTTP_200_OK
mock_crawl.delay.assert_not_called()
mock_activity.delay.assert_called_once()
@patch("plane.app.views.issue.link.base_host", return_value="https://app.plane.so")
@patch("plane.app.views.issue.link.issue_activity")
@patch("plane.app.views.issue.link.crawl_work_item_link_title")
@patch("plane.app.views.issue.link.IssueLinkSerializer")
@patch("plane.app.views.issue.link.IssueLink.objects.get")
def test_partial_update_triggers_crawl_when_url_changed(
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity, mock_base_host
):
"""Updating url to a new value MUST trigger crawl_work_item_link_title."""
initial_url = "https://github.com/makeplane/plane"
new_url = "https://github.com/makeplane/plane/pull/123"
mock_instance = MagicMock()
mock_instance.id = self.link_id
mock_instance.url = initial_url
mock_get_link.return_value = mock_instance
mock_serializer_instance = MagicMock()
mock_serializer_instance.is_valid.return_value = True
mock_serializer_instance.data = {
"id": self.link_id,
"title": "Old Title",
"url": new_url,
"metadata": {},
}
mock_serializer_cls.return_value = mock_serializer_instance
mock_queryset = MagicMock()
mock_queryset.get.return_value = mock_instance
self.view.get_queryset = MagicMock(return_value=mock_queryset)
request = self._create_mock_request({"url": new_url})
response = self.view.partial_update(
request,
slug=self.workspace_slug,
project_id=self.project_id,
issue_id=self.issue_id,
pk=self.link_id,
)
assert response.status_code == status.HTTP_200_OK
mock_crawl.delay.assert_called_once_with(self.link_id, new_url)
mock_activity.delay.assert_called_once()
@pytest.mark.unit
class TestIssueLinkDetailPatch:
@pytest.fixture(autouse=True)
def setup_view(self):
self.factory = APIRequestFactory()
self.view = IssueLinkDetailAPIEndpoint()
self.workspace_slug = "test-workspace"
self.project_id = str(uuid.uuid4())
self.issue_id = str(uuid.uuid4())
self.link_id = str(uuid.uuid4())
self.user_id = str(uuid.uuid4())
def _create_mock_request(self, data):
wsgi_request = self.factory.patch("/api/test/", data=data, format="json")
request = Request(wsgi_request, parsers=[JSONParser()])
request.user = MagicMock()
request.user.id = self.user_id
return request
@patch("plane.api.views.issue.issue_activity")
@patch("plane.api.views.issue.crawl_work_item_link_title")
@patch("plane.api.views.issue.IssueLinkSerializer")
@patch("plane.api.views.issue.IssueLink.objects.get")
def test_patch_does_not_recrawl_when_url_unchanged(
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity
):
initial_url = "https://github.com/makeplane/plane"
mock_instance = MagicMock()
mock_instance.id = self.link_id
mock_instance.url = initial_url
mock_get_link.return_value = mock_instance
mock_serializer_instance = MagicMock()
mock_serializer_instance.is_valid.return_value = True
mock_serializer_instance.data = {"id": self.link_id, "url": initial_url}
mock_serializer_cls.return_value = mock_serializer_instance
response = self.view.patch(
self._create_mock_request({"title": "Custom Renamed Title"}),
slug=self.workspace_slug,
project_id=self.project_id,
issue_id=self.issue_id,
pk=self.link_id,
)
assert response.status_code == status.HTTP_200_OK
mock_crawl.delay.assert_not_called()
mock_activity.delay.assert_called_once()
@patch("plane.api.views.issue.issue_activity")
@patch("plane.api.views.issue.crawl_work_item_link_title")
@patch("plane.api.views.issue.IssueLinkSerializer")
@patch("plane.api.views.issue.IssueLink.objects.get")
def test_patch_triggers_crawl_when_url_changed(
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity
):
initial_url = "https://github.com/makeplane/plane"
new_url = "https://github.com/makeplane/plane/pull/123"
mock_instance = MagicMock()
mock_instance.id = self.link_id
mock_instance.url = initial_url
mock_get_link.return_value = mock_instance
mock_serializer_instance = MagicMock()
mock_serializer_instance.is_valid.return_value = True
mock_serializer_instance.data = {"id": self.link_id, "url": new_url}
mock_serializer_cls.return_value = mock_serializer_instance
response = self.view.patch(
self._create_mock_request({"url": new_url}),
slug=self.workspace_slug,
project_id=self.project_id,
issue_id=self.issue_id,
pk=self.link_id,
)
assert response.status_code == status.HTTP_200_OK
mock_crawl.delay.assert_called_once_with(self.link_id, new_url)
mock_activity.delay.assert_called_once()