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
525 lines
21 KiB
Python
525 lines
21 KiB
Python
# 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()
|