From 05ac54bc4960e0585ca0e1b61dd555438a946aa3 Mon Sep 17 00:00:00 2001 From: Seena Fallah Date: Sat, 11 Jul 2026 22:49:55 +0000 Subject: [PATCH] feat(api): create/list/retrieve/update workspaces via the token API Add a public (API-token) workspace surface so a workspace can be provisioned headlessly, mirroring the internal app-API create logic (plane/app/views/workspace/base.py::WorkSpaceViewSet). - POST /api/v1/workspaces/ -> create; token user becomes owner + admin (WorkspaceMember role 20); seeds default data via workspace_seed.delay. Slug is optional and auto-generated from the name (slugify, 48-char cap, numeric de-dup, reserved-slug aware) for headless provisioning; an explicit slug is validated exactly like the internal API. - GET /api/v1/workspaces/ -> list workspaces the user is a member of. - GET /api/v1/workspaces/{slug}/ -> retrieve (members only, else 404). - PATCH /api/v1/workspaces/{slug}/ -> partial update (admin only, matching the internal @allow_permission([ROLE.ADMIN]) gate). Validation mirrors WorkSpaceSerializer: name <=80 + has_alphanumeric + no embedded URL; slug <=48 + ^[a-zA-Z0-9_-]+$ + RESTRICTED_WORKSPACE_SLUGS; owner is read-only; DISABLE_WORKSPACE_CREATION honoured. Routes are registered ahead of the invite/sticky DefaultRouters so the workspaces/{slug}/ detail route wins over their API-root views. Additive only; no model changes or migrations. Adds contract + unit tests. Signed-off-by: Seena Fallah --- apps/api/plane/api/serializers/__init__.py | 2 +- apps/api/plane/api/serializers/workspace.py | 59 ++++ apps/api/plane/api/urls/__init__.py | 5 + apps/api/plane/api/urls/workspace.py | 23 ++ apps/api/plane/api/views/__init__.py | 5 + apps/api/plane/api/views/workspace.py | 323 ++++++++++++++++++ .../tests/contract/api/test_workspaces.py | 314 +++++++++++++++++ 7 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 apps/api/plane/api/urls/workspace.py create mode 100644 apps/api/plane/api/views/workspace.py create mode 100644 apps/api/plane/tests/contract/api/test_workspaces.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 2ab639d5466..1ac01ca72b7 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -3,7 +3,7 @@ # See the LICENSE file for details. from .user import UserLiteSerializer -from .workspace import WorkspaceLiteSerializer +from .workspace import WorkspaceLiteSerializer, WorkspaceSerializer from .project import ( ProjectSerializer, ProjectLiteSerializer, diff --git a/apps/api/plane/api/serializers/workspace.py b/apps/api/plane/api/serializers/workspace.py index 6b85fcabcb3..3415d49d2a1 100644 --- a/apps/api/plane/api/serializers/workspace.py +++ b/apps/api/plane/api/serializers/workspace.py @@ -2,8 +2,17 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# Python imports +import re + +# Third party imports +from rest_framework import serializers + # Module imports from plane.db.models import Workspace +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.utils.content_validator import has_alphanumeric +from plane.utils.url import contains_url from .base import BaseSerializer @@ -19,3 +28,53 @@ class Meta: model = Workspace fields = ["name", "slug", "id"] read_only_fields = fields + + +class WorkspaceSerializer(BaseSerializer): + """ + Full workspace serializer for the public (token) API. + + Mirrors the internal app ``WorkSpaceSerializer`` so the token API enforces + the exact same name limits, content checks, and slug rules. ``total_members`` + and ``role`` are read-only annotations populated by the viewset queryset; + ``logo_url`` is a model property. + """ + + 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): + # Reject names that embed a URL (mirrors the app serializer). + if contains_url(value): + raise serializers.ValidationError("Name must not contain URLs") + # Reject symbol-only names like "-_________-" that carry no letter or + # digit. Mirrors the frontend HAS_ALPHANUMERIC_REGEX check so the rule + # cannot be bypassed via a direct API call. + if not has_alphanumeric(value): + raise serializers.ValidationError("Name must contain at least one letter or number") + return value + + def validate_slug(self, value): + # Reject reserved slugs that collide with first-class routes. + if value in RESTRICTED_WORKSPACE_SLUGS: + raise serializers.ValidationError("Slug is not valid") + # Slug may 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: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + "logo_url", + ] diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 4a202431bc7..8c0cbf538f8 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -14,8 +14,13 @@ from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns from .sticky import urlpatterns as sticky_patterns +from .workspace import urlpatterns as workspace_patterns urlpatterns = [ + # Workspace patterns come first: the invite and sticky DefaultRouters mount + # an API-root view at ``workspaces//``, so the workspace detail route + # must be registered ahead of them to take precedence for that exact path. + *workspace_patterns, *asset_patterns, *cycle_patterns, *intake_patterns, diff --git a/apps/api/plane/api/urls/workspace.py b/apps/api/plane/api/urls/workspace.py new file mode 100644 index 00000000000..2c7f569f8ae --- /dev/null +++ b/apps/api/plane/api/urls/workspace.py @@ -0,0 +1,23 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views import ( + WorkspaceListCreateAPIEndpoint, + WorkspaceDetailAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces/", + WorkspaceListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="workspaces", + ), + path( + "workspaces//", + WorkspaceDetailAPIEndpoint.as_view(http_method_names=["get", "patch"]), + name="workspace", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index e8549afb437..094e9739924 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -63,3 +63,8 @@ from .invite import WorkspaceInvitationsViewset from .sticky import StickyViewSet + +from .workspace import ( + WorkspaceListCreateAPIEndpoint, + WorkspaceDetailAPIEndpoint, +) diff --git a/apps/api/plane/api/views/workspace.py b/apps/api/plane/api/views/workspace.py new file mode 100644 index 00000000000..2ab39ac0eec --- /dev/null +++ b/apps/api/plane/api/views/workspace.py @@ -0,0 +1,323 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Python imports +import os + +# Django imports +from django.db import IntegrityError, transaction +from django.db.models import F, Func, OuterRef +from django.utils.text import slugify + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiRequest + +# Module imports +from plane.api.serializers import WorkspaceSerializer +from plane.api.views.base import BaseAPIView +from plane.app.permissions import WorkSpaceBasePermission, WorkspaceOwnerPermission +from plane.db.models import Workspace, WorkspaceMember +from plane.bgtasks.workspace_seed_task import workspace_seed +from plane.license.utils.instance_value import get_configuration_value +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.utils.openapi import ( + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + UNAUTHORIZED_RESPONSE, + FORBIDDEN_RESPONSE, + NOT_FOUND_RESPONSE, + VALIDATION_ERROR_RESPONSE, + CONFLICT_RESPONSE, + create_paginated_response, +) + +# Workspace slug field length (mirrors Workspace.slug max_length). +WORKSPACE_SLUG_MAX_LENGTH = 48 + + +def generate_unique_workspace_slug(name): + """Derive a unique, URL-safe workspace slug from a workspace name. + + Used when the caller does not supply an explicit slug so a workspace can be + provisioned headlessly. The result respects ``Workspace.slug``'s length + limit, avoids reserved slugs, and is guaranteed not to collide with an + existing workspace at generation time (the DB ``unique`` constraint remains + the final guard against races). + """ + base = slugify(name)[:WORKSPACE_SLUG_MAX_LENGTH] + # ``slugify`` can strip a purely non-ASCII name down to an empty string; + # fall back to a stable prefix so we always have something to suffix. + if not base: + base = "workspace" + + candidate = base + index = 1 + while candidate in RESTRICTED_WORKSPACE_SLUGS or Workspace.objects.filter(slug=candidate).exists(): + suffix = f"-{index}" + candidate = f"{base[: WORKSPACE_SLUG_MAX_LENGTH - len(suffix)]}{suffix}" + index += 1 + return candidate + + +def workspace_queryset(user): + """Workspaces the given user is an active member of, annotated for output. + + Annotates ``total_members`` (non-bot active members) and the caller's + ``role`` so the serializer can surface them, mirroring the internal + app ``WorkSpaceViewSet`` queryset. + """ + member_count = ( + WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + role = WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=user, is_active=True).values("role") + return ( + Workspace.objects.filter(workspace_member__member=user, workspace_member__is_active=True) + .select_related("owner") + .annotate(total_members=member_count, role=role) + .distinct() + ) + + +class WorkspaceListCreateAPIEndpoint(BaseAPIView): + """Workspace list and create endpoint for the public (token) API.""" + + serializer_class = WorkspaceSerializer + model = Workspace + permission_classes = [WorkSpaceBasePermission] + use_read_replica = True + + def get_queryset(self): + return workspace_queryset(self.request.user).order_by("name") + + @extend_schema( + operation_id="list_workspaces", + tags=["Workspaces"], + summary="List workspaces", + description="Retrieve all workspaces the authenticated user is an active member of.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + WorkspaceSerializer, + "PaginatedWorkspaceResponse", + "Paginated list of workspaces", + "Paginated Workspaces", + ), + 401: UNAUTHORIZED_RESPONSE, + }, + ) + def get(self, request): + """List workspaces + + Retrieve all workspaces the authenticated user is an active member of, + ordered by name with member counts and the user's role. + """ + return self.paginate( + request=request, + queryset=self.get_queryset(), + on_results=lambda workspaces: ( + WorkspaceSerializer(workspaces, many=True, fields=self.fields, expand=self.expand).data + ), + ) + + @extend_schema( + operation_id="create_workspace", + tags=["Workspaces"], + summary="Create workspace", + description=( + "Create a new workspace and make the token's user its owner and admin. " + "When ``slug`` is omitted a unique slug is generated from ``name`` so " + "workspaces can be provisioned headlessly." + ), + request=OpenApiRequest(request=WorkspaceSerializer), + responses={ + 201: OpenApiResponse( + description="Workspace created successfully", + response=WorkspaceSerializer, + ), + 400: VALIDATION_ERROR_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 409: CONFLICT_RESPONSE, + }, + ) + def post(self, request): + """Create workspace + + Create a new workspace, generate a unique slug when one is not supplied, + and register the token's user as the owner and admin (role 20). Seeds the + workspace with default project data asynchronously. + """ + # Respect the instance-level switch that disables workspace creation, + # exactly as the internal app API does. + (disable_workspace_creation,) = get_configuration_value( + [ + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + } + ] + ) + if disable_workspace_creation == "1": + return Response( + {"error": "Workspace creation is not allowed"}, + status=status.HTTP_403_FORBIDDEN, + ) + + data = {**request.data} + name = data.get("name") + if not name: + return Response( + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Auto-generate a unique slug when the caller does not supply one; an + # explicit slug is validated (format/reserved/uniqueness) by the + # serializer just like the internal API. + if not data.get("slug"): + data["slug"] = generate_unique_workspace_slug(name) + + serializer = WorkspaceSerializer(data=data) + try: + if serializer.is_valid(): + # Create the workspace and register the creator as its + # owner/admin (role 20) in one transaction so a membership + # failure can never leave an orphaned, owner-less workspace. + with transaction.atomic(): + serializer.save(owner=request.user) + WorkspaceMember.objects.create( + workspace_id=serializer.data["id"], + member=request.user, + role=20, + company_role=data.get("company_role", ""), + ) + + # Transaction committed: build the response and seed in the + # background. total_members mirrors the list/retrieve annotation + # (non-bot, active members only). + total_members = WorkspaceMember.objects.filter( + workspace_id=serializer.data["id"], member__is_bot=False, is_active=True + ).count() + response_data = serializer.data + response_data["total_members"] = total_members + response_data["role"] = 20 + + # Seed default project data in the background. + workspace_seed.delay(serializer.data["id"]) + + return Response(response_data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + # The slug is unique; a concurrent create can still lose the race + # after validation passes. Report only that as a conflict and let + # any other integrity error bubble to the global handler instead of + # mislabelling it as a slug collision. + if "already exists" in str(e): + return Response( + {"slug": "The workspace with the slug already exists"}, + status=status.HTTP_409_CONFLICT, + ) + raise + + +class WorkspaceDetailAPIEndpoint(BaseAPIView): + """Workspace retrieve and update endpoint for the public (token) API.""" + + serializer_class = WorkspaceSerializer + model = Workspace + permission_classes = [WorkSpaceBasePermission] + use_read_replica = True + + def get_permissions(self): + # Mirror the internal WorkSpaceViewSet: retrieve is available to any + # active member, but updates are admin-only (the internal + # partial_update is decorated ``@allow_permission([ROLE.ADMIN])``). + if self.request.method == "PATCH": + return [WorkspaceOwnerPermission()] + return [WorkSpaceBasePermission()] + + def get_queryset(self): + return workspace_queryset(self.request.user) + + @extend_schema( + operation_id="retrieve_workspace", + tags=["Workspaces"], + summary="Retrieve workspace", + description="Retrieve details of a workspace the authenticated user is an active member of.", + parameters=[ + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Workspace details", + response=WorkspaceSerializer, + ), + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug): + """Retrieve workspace + + Retrieve details of a workspace the authenticated user is an active + member of. Non-members receive a 404. + """ + workspace = self.get_queryset().get(slug=slug) + serializer = WorkspaceSerializer(workspace, fields=self.fields, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + operation_id="update_workspace", + tags=["Workspaces"], + summary="Update workspace", + description="Partially update a workspace. Requires the token's user to be an admin of the workspace.", + request=OpenApiRequest(request=WorkspaceSerializer), + responses={ + 200: OpenApiResponse( + description="Workspace updated successfully", + response=WorkspaceSerializer, + ), + 400: VALIDATION_ERROR_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + 409: CONFLICT_RESPONSE, + }, + ) + def patch(self, request, slug): + """Update workspace + + Partially update a workspace's properties (name, timezone, logo, slug, ...). + Restricted to workspace admins. Name and slug are validated with the same + rules as creation. The owner is immutable through this endpoint. + """ + workspace = self.get_queryset().get(slug=slug) + serializer = WorkspaceSerializer(workspace, data=request.data, partial=True) + try: + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + # Only a slug unique-violation is a client-facing conflict; let any + # other integrity error bubble to the global handler. + if "already exists" in str(e): + return Response( + {"slug": "The workspace with the slug already exists"}, + status=status.HTTP_409_CONFLICT, + ) + raise diff --git a/apps/api/plane/tests/contract/api/test_workspaces.py b/apps/api/plane/tests/contract/api/test_workspaces.py new file mode 100644 index 00000000000..e7cc3f7363b --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_workspaces.py @@ -0,0 +1,314 @@ +# 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 (token) workspace API. + +Covers POST/GET /api/v1/workspaces/ and GET/PATCH /api/v1/workspaces/{slug}/, +which mirror the internal app workspace-create logic so a workspace can be +provisioned headlessly with an API key. +""" + +from unittest import mock +from uuid import uuid4 + +import pytest +from django.db import IntegrityError +from rest_framework import status +from rest_framework.test import APIClient + +from plane.api.views.workspace import generate_unique_workspace_slug +from plane.db.models import User, Workspace, WorkspaceMember + + +LIST_CREATE_URL = "/api/v1/workspaces/" + + +def detail_url(slug): + return f"/api/v1/workspaces/{slug}/" + + +@pytest.fixture +def foreign_workspace(db): + """A workspace owned by a different user; the token user is not a member.""" + 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() + workspace = Workspace.objects.create(name="Foreign", owner=other, slug=f"foreign-{unique_id}") + WorkspaceMember.objects.create(workspace=workspace, member=other, role=20) + return workspace + + +@pytest.fixture +def member_workspace(db, create_user): + """A workspace owned by someone else where the token user is a plain Member (role 15).""" + unique_id = uuid4().hex[:8] + owner = User.objects.create( + email=f"owner-{unique_id}@plane.so", + username=f"owner_user_{unique_id}", + first_name="Owner", + last_name="User", + ) + owner.set_password("test-password") + owner.save() + workspace = Workspace.objects.create(name="Shared", owner=owner, slug=f"shared-{unique_id}") + WorkspaceMember.objects.create(workspace=workspace, member=owner, role=20) + WorkspaceMember.objects.create(workspace=workspace, member=create_user, role=15) + return workspace + + +@pytest.fixture +def admin_non_owner_workspace(db, create_user): + """A workspace owned by someone else where the token user is a role-20 Admin (not the owner).""" + unique_id = uuid4().hex[:8] + owner = User.objects.create( + email=f"owner-{unique_id}@plane.so", + username=f"owner_user_{unique_id}", + first_name="Owner", + last_name="User", + ) + owner.set_password("test-password") + owner.save() + workspace = Workspace.objects.create(name="Co-owned", owner=owner, slug=f"co-owned-{unique_id}") + WorkspaceMember.objects.create(workspace=workspace, member=owner, role=20) + WorkspaceMember.objects.create(workspace=workspace, member=create_user, role=20) + return workspace + + +@pytest.mark.contract +class TestWorkspaceListCreateAPIEndpoint: + """Contract tests for POST/GET /api/v1/workspaces/.""" + + @pytest.mark.django_db + @mock.patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_with_explicit_slug(self, mock_seed, api_key_client, create_user): + payload = {"name": "Acme", "slug": "acme-inc"} + + response = api_key_client.post(LIST_CREATE_URL, payload, format="json") + + assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}" + workspace = Workspace.objects.get(id=response.data["id"]) + assert workspace.slug == "acme-inc" + # Token user becomes the owner and an admin member (role 20). + assert workspace.owner == create_user + assert WorkspaceMember.objects.filter(workspace=workspace, member=create_user, role=20).count() == 1 + # Response carries the derived membership fields. + assert response.data["role"] == 20 + assert response.data["total_members"] == 1 + # Seeding was dispatched exactly once for the new workspace. + mock_seed.assert_called_once_with(response.data["id"]) + + @pytest.mark.django_db + @mock.patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_auto_generates_slug(self, mock_seed, api_key_client, create_user): + """Omitting slug must auto-generate a URL-safe slug from the name.""" + response = api_key_client.post(LIST_CREATE_URL, {"name": "Acme Corp"}, format="json") + + assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}" + workspace = Workspace.objects.get(id=response.data["id"]) + assert workspace.slug == "acme-corp" + + @pytest.mark.django_db + @mock.patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_auto_slug_avoids_collision(self, mock_seed, api_key_client, create_user): + """An auto-generated slug must not collide with an existing workspace.""" + Workspace.objects.create(name="Existing", owner=create_user, slug="acme-corp") + + response = api_key_client.post(LIST_CREATE_URL, {"name": "Acme Corp"}, format="json") + + assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}" + workspace = Workspace.objects.get(id=response.data["id"]) + assert workspace.slug == "acme-corp-1" + + @pytest.mark.django_db + def test_create_workspace_requires_name(self, api_key_client): + response = api_key_client.post(LIST_CREATE_URL, {}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert Workspace.objects.count() == 0 + + @pytest.mark.django_db + @mock.patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_duplicate_slug_returns_400(self, mock_seed, api_key_client): + """A duplicate explicit slug is rejected by the serializer (mirrors the + internal API, which returns a field-shaped 400 rather than a 409).""" + api_key_client.post(LIST_CREATE_URL, {"name": "Acme", "slug": "acme"}, format="json") + + response = api_key_client.post(LIST_CREATE_URL, {"name": "Acme Two", "slug": "acme"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "slug" in response.data + assert Workspace.objects.filter(slug="acme").count() == 1 + + @pytest.mark.django_db + def test_create_workspace_rejects_restricted_slug(self, api_key_client): + response = api_key_client.post(LIST_CREATE_URL, {"name": "Acme", "slug": "api"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "slug" in response.data + assert Workspace.objects.count() == 0 + + @pytest.mark.django_db + def test_create_workspace_rejects_invalid_slug(self, api_key_client): + response = api_key_client.post(LIST_CREATE_URL, {"name": "Acme", "slug": "bad slug!"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "slug" in response.data + + @pytest.mark.django_db + def test_create_workspace_rejects_name_with_url(self, api_key_client): + response = api_key_client.post( + LIST_CREATE_URL, {"name": "https://evil.example.com", "slug": "evil"}, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + assert Workspace.objects.count() == 0 + + @pytest.mark.django_db + def test_create_workspace_requires_authentication(self): + response = APIClient().post(LIST_CREATE_URL, {"name": "Acme", "slug": "acme"}, format="json") + + assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) + assert Workspace.objects.count() == 0 + + @pytest.mark.django_db + def test_list_returns_only_member_workspaces(self, api_key_client, workspace, foreign_workspace): + response = api_key_client.get(LIST_CREATE_URL) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + slugs = {row["slug"] for row in response.data["results"]} + assert workspace.slug in slugs + assert foreign_workspace.slug not in slugs + + @pytest.mark.django_db + def test_create_slug_collision_race_returns_409(self, api_key_client): + """A slug unique-violation that slips past validation (a create race) + is reported as a 409 conflict.""" + collision = IntegrityError('unique constraint "workspaces_slug_key" ... already exists') + with mock.patch("plane.api.views.workspace.WorkspaceSerializer.save", side_effect=collision): + response = api_key_client.post(LIST_CREATE_URL, {"name": "Race", "slug": "race"}, format="json") + + assert response.status_code == status.HTTP_409_CONFLICT + assert "slug" in response.data + + @pytest.mark.django_db + def test_create_unexpected_integrity_error_not_reported_as_slug_conflict(self, api_key_client): + """A non-slug IntegrityError must not be mislabeled as a slug conflict; + it bubbles to the global handler (400) instead of returning 409.""" + with mock.patch( + "plane.api.views.workspace.WorkspaceSerializer.save", + side_effect=IntegrityError("some unrelated constraint violated"), + ): + response = api_key_client.post(LIST_CREATE_URL, {"name": "Boom", "slug": "boom"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code != status.HTTP_409_CONFLICT + + +@pytest.mark.contract +class TestWorkspaceDetailAPIEndpoint: + """Contract tests for GET/PATCH /api/v1/workspaces/{slug}/.""" + + @pytest.mark.django_db + def test_retrieve_workspace(self, api_key_client, workspace): + response = api_key_client.get(detail_url(workspace.slug)) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + assert response.data["slug"] == workspace.slug + assert response.data["role"] == 20 + assert response.data["total_members"] == 1 + + @pytest.mark.django_db + def test_retrieve_workspace_non_member_returns_404(self, api_key_client, foreign_workspace): + response = api_key_client.get(detail_url(foreign_workspace.slug)) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_update_workspace_name(self, api_key_client, workspace): + response = api_key_client.patch(detail_url(workspace.slug), {"name": "Renamed"}, format="json") + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + workspace.refresh_from_db() + assert workspace.name == "Renamed" + + @pytest.mark.django_db + def test_update_workspace_rejects_name_with_url(self, api_key_client, workspace): + response = api_key_client.patch(detail_url(workspace.slug), {"name": "https://evil.example.com"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + @pytest.mark.django_db + def test_update_workspace_non_member_forbidden(self, api_key_client, foreign_workspace): + response = api_key_client.patch(detail_url(foreign_workspace.slug), {"name": "Hijack"}, format="json") + + # Non-members are rejected by the workspace permission (403); a member + # of no such workspace never reaches the object. + assert response.status_code in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) + + @pytest.mark.django_db + def test_member_can_retrieve_but_not_update(self, api_key_client, member_workspace): + """A plain member (role 15) may read the workspace but not update it, + mirroring the internal admin-only partial_update.""" + get_response = api_key_client.get(detail_url(member_workspace.slug)) + assert get_response.status_code == status.HTTP_200_OK, f"Got {get_response.status_code}: {get_response.data!r}" + assert get_response.data["role"] == 15 + + patch_response = api_key_client.patch(detail_url(member_workspace.slug), {"name": "Renamed"}, format="json") + assert patch_response.status_code == status.HTTP_403_FORBIDDEN + member_workspace.refresh_from_db() + assert member_workspace.name == "Shared" + + @pytest.mark.django_db + def test_update_workspace_as_admin_non_owner(self, api_key_client, admin_non_owner_workspace): + """Update is admin-only, not owner-only: a role-20 admin who is not the + workspace owner can still update it.""" + response = api_key_client.patch( + detail_url(admin_non_owner_workspace.slug), {"name": "Renamed By Admin"}, format="json" + ) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + admin_non_owner_workspace.refresh_from_db() + assert admin_non_owner_workspace.name == "Renamed By Admin" + + @pytest.mark.django_db + def test_update_unexpected_integrity_error_not_reported_as_slug_conflict(self, api_key_client, workspace): + """A non-slug IntegrityError during update bubbles to the global handler + (400) rather than being mislabeled as a 409 slug conflict.""" + with mock.patch( + "plane.api.views.workspace.WorkspaceSerializer.save", + side_effect=IntegrityError("some unrelated constraint violated"), + ): + response = api_key_client.patch(detail_url(workspace.slug), {"name": "Boom"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code != status.HTTP_409_CONFLICT + + +@pytest.mark.contract +@pytest.mark.django_db +class TestGenerateUniqueWorkspaceSlug: + """Unit-level checks for the slug generator used by headless provisioning.""" + + def test_slugifies_name(self, db): + assert generate_unique_workspace_slug("Hello World") == "hello-world" + + def test_falls_back_for_symbol_only_name(self, db): + assert generate_unique_workspace_slug("!!!") == "workspace" + + def test_truncates_to_max_length(self, db): + slug = generate_unique_workspace_slug("a" * 100) + assert len(slug) <= 48 + + def test_appends_suffix_on_collision(self, db, create_user): + Workspace.objects.create(name="Taken", owner=create_user, slug="taken") + assert generate_unique_workspace_slug("Taken") == "taken-1"