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