Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ mediafiles
.env
.DS_Store
logs/
htmlcov/
.coverage

node_modules/
assets/dist/
Expand Down
25 changes: 25 additions & 0 deletions apiserver/.coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[run]
source = plane
omit =
*/tests/*
*/migrations/*
*/settings/*
*/wsgi.py
*/asgi.py
*/urls.py
manage.py
*/admin.py
*/apps.py

[report]
exclude_lines =
pragma: no cover
def __repr__
if self.debug:
raise NotImplementedError
if __name__ == .__main__.
pass
raise ImportError

[html]
directory = htmlcov
3 changes: 2 additions & 1 deletion apiserver/plane/app/serializers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ class Meta:
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project")
fields = ("id", "role", "member", "project", "created_at")
read_only_fields = ["created_at"]


class ProjectMemberInviteSerializer(BaseSerializer):
Expand Down
11 changes: 11 additions & 0 deletions apiserver/plane/app/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@

# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import contains_url

from .base import BaseSerializer


class UserSerializer(BaseSerializer):
def validate_first_name(self, value):
if contains_url(value):
raise serializers.ValidationError("First name cannot contain a URL.")
return value

def validate_last_name(self, value):
if contains_url(value):
raise serializers.ValidationError("Last name cannot contain a URL.")
return value

class Meta:
model = User
# Exclude password field from the serializer
Expand Down
13 changes: 13 additions & 0 deletions apiserver/plane/app/serializers/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,34 @@
WorkspaceUserPreference,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url

# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
import re


class WorkSpaceSerializer(DynamicBaseSerializer):
total_members = serializers.IntegerField(read_only=True)
logo_url = serializers.CharField(read_only=True)
role = serializers.IntegerField(read_only=True)

def validate_name(self, value):
# Check if the name contains a URL
if contains_url(value):
raise serializers.ValidationError("Name must not contain URLs")
return value

def validate_slug(self, value):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
# Slug should only contain alphanumeric characters, hyphens, and underscores
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
raise serializers.ValidationError(
"Slug can only contain letters, numbers, hyphens (-), and underscores (_)"
)
return value

class Meta:
Expand Down
7 changes: 7 additions & 0 deletions apiserver/plane/app/views/workspace/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.workspace_seed_task import workspace_seed
from plane.utils.url import contains_url


class WorkSpaceViewSet(BaseViewSet):
Expand Down Expand Up @@ -109,6 +110,12 @@ def create(self, request):
status=status.HTTP_400_BAD_REQUEST,
)

if contains_url(name):
return Response(
{"error": "Name cannot contain a URL"},
status=status.HTTP_400_BAD_REQUEST,
)

if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
Expand Down
30 changes: 15 additions & 15 deletions apiserver/plane/authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@
# credentials
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"),
# csrf token
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
# Magic sign in
Expand All @@ -56,56 +56,56 @@
path(
"spaces/magic-generate/",
MagicGenerateSpaceEndpoint.as_view(),
name="magic-generate",
name="space-magic-generate",
),
path(
"spaces/magic-sign-in/",
MagicSignInSpaceEndpoint.as_view(),
name="magic-sign-in",
name="space-magic-sign-in",
),
path(
"spaces/magic-sign-up/",
MagicSignUpSpaceEndpoint.as_view(),
name="magic-sign-up",
name="space-magic-sign-up",
),
## Google Oauth
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
path("google/callback/", GoogleCallbackEndpoint.as_view(), name="google-callback"),
path(
"spaces/google/",
GoogleOauthInitiateSpaceEndpoint.as_view(),
name="google-initiate",
name="space-google-initiate",
),
path(
"google/callback/",
"spaces/google/callback/",
GoogleCallbackSpaceEndpoint.as_view(),
name="google-callback",
name="space-google-callback",
),
## Github Oauth
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
path("github/callback/", GitHubCallbackEndpoint.as_view(), name="github-callback"),
path(
"spaces/github/",
GitHubOauthInitiateSpaceEndpoint.as_view(),
name="github-initiate",
name="space-github-initiate",
),
path(
"spaces/github/callback/",
GitHubCallbackSpaceEndpoint.as_view(),
name="github-callback",
name="space-github-callback",
),
## Gitlab Oauth
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
path("gitlab/callback/", GitLabCallbackEndpoint.as_view(), name="gitlab-callback"),
path(
"spaces/gitlab/",
GitLabOauthInitiateSpaceEndpoint.as_view(),
name="gitlab-initiate",
name="space-gitlab-initiate",
),
path(
"spaces/gitlab/callback/",
GitLabCallbackSpaceEndpoint.as_view(),
name="gitlab-callback",
name="space-gitlab-callback",
),
# Email Check
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
Expand All @@ -120,12 +120,12 @@
path(
"spaces/forgot-password/",
ForgotPasswordSpaceEndpoint.as_view(),
name="forgot-password",
name="space-forgot-password",
),
path(
"spaces/reset-password/<uidb64>/<token>/",
ResetPasswordSpaceEndpoint.as_view(),
name="forgot-password",
name="space-forgot-password",
),
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
Expand Down
143 changes: 143 additions & 0 deletions apiserver/plane/tests/README.md
Original file line number Diff line number Diff line change
@@ -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, MongoDB)
- `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`, `mock_mongodb`, 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.
Loading