From 7457a404a02273cb8f3b14ce574a8cc268aaa558 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:17:48 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add tests for API permissions, project views/models, web user store --- .../app/permissions/test_base_permissions.py | 203 ++++++ .../permissions/test_project_permissions.py | 299 +++++++++ .../unit/app/views/project/test_base_view.py | 577 ++++++++++++++++++ .../unit/db/models/test_project_model.py | 238 ++++++++ .../__tests__/base-permissions.store.spec.ts | 433 +++++++++++++ 5 files changed, 1750 insertions(+) create mode 100644 apps/api/plane/tests/unit/app/permissions/test_base_permissions.py create mode 100644 apps/api/plane/tests/unit/app/permissions/test_project_permissions.py create mode 100644 apps/api/plane/tests/unit/app/views/project/test_base_view.py create mode 100644 apps/api/plane/tests/unit/db/models/test_project_model.py create mode 100644 apps/web/core/store/user/__tests__/base-permissions.store.spec.ts diff --git a/apps/api/plane/tests/unit/app/permissions/test_base_permissions.py b/apps/api/plane/tests/unit/app/permissions/test_base_permissions.py new file mode 100644 index 00000000000..a920e740c70 --- /dev/null +++ b/apps/api/plane/tests/unit/app/permissions/test_base_permissions.py @@ -0,0 +1,203 @@ +import types +from types import SimpleNamespace +from unittest.mock import patch, Mock +import pytest + +# Testing library/framework: pytest + pytest-django style with unittest.mock for patching. + +# Attempt to import the real symbols; if path changes, adjust here. +# The implementation imports WorkspaceMember, ProjectMember inside its module, +# so we patch those classes in that module's namespace. +try: + # Common plausible locations — adjust if repository structure differs. + from plane.app.permissions.base_permissions import allow_permission, ROLE # type: ignore + TARGET_MODULE = "plane.app.permissions.base_permissions" +except Exception: + # Fallback: the PR context provided the code; assume it lives alongside tests + # under a base_permissions module in the same package path. + from apps.api.plane.tests.unit.app.permissions import base_permissions # type: ignore + allow_permission = base_permissions.allow_permission + ROLE = base_permissions.ROLE + TARGET_MODULE = "apps.api.plane.tests.unit.app.permissions.base_permissions" + + +class _Req(SimpleNamespace): + pass + + +def _mk_view(): + called = {"count": 0, "args": None, "kwargs": None} + + def view(self, request, *args, **kwargs): + called["count"] += 1 + called["args"] = args + called["kwargs"] = kwargs + return {"ok": True, "args": args, "kwargs": kwargs} + + return view, called + + +def _stub_qs(return_value: bool): + return SimpleNamespace(exists=lambda: return_value) + + +@pytest.fixture +def user(): + return SimpleNamespace(id=1, username="u1") + + +@pytest.fixture +def req(user): + return _Req(user=user) + + +@pytest.fixture +def kwargs_base(): + return {"slug": "ws-1", "project_id": 42, "pk": 777} + + +def test_creator_override_allows_when_creator_true_and_model_matches(req, kwargs_base): + view, called = _mk_view() + + # Build a fake model with objects.filter(...).exists() -> True only when created_by == req.user and id == pk + class FakeModel: + pass + + def filter_model(**kw): + return _stub_qs(kw.get("created_by") is req.user and kw.get("id") == kwargs_base["pk"]) + + FakeModel.objects = SimpleNamespace(filter=lambda **kw: filter_model(**kw)) + + wrapped = allow_permission(allowed_roles=[ROLE.GUEST], level="PROJECT", creator=True, model=FakeModel)(view) + + # Even without any Project/Workspace membership, creator path should allow + with patch(f"{TARGET_MODULE}.WorkspaceMember") as WM, patch(f"{TARGET_MODULE}.ProjectMember") as PM: + # Ensure other paths would deny if evaluated + WM.objects = SimpleNamespace(filter=lambda **kw: _stub_qs(False)) + PM.objects = SimpleNamespace(filter=lambda **kw: _stub_qs(False)) + + res = wrapped(None, req, **kwargs_base) + + assert called["count"] == 1, "View should be called due to creator override" + assert res == {"ok": True, "args": (), "kwargs": kwargs_base} + + +def test_workspace_level_allows_when_member_has_allowed_role(req, kwargs_base): + view, called = _mk_view() + wrapped = allow_permission(allowed_roles=[ROLE.MEMBER, ROLE.ADMIN], level="WORKSPACE")(view) + + def wm_filter(**kw): + # Expect role__in to include MEMBER/ADMIN, ensure is_active True and slug match + role_in = kw.get("role__in", []) + return _stub_qs( + kw.get("member") is req.user + and kw.get("workspace__slug") == kwargs_base["slug"] + and kw.get("is_active") is True + and (ROLE.MEMBER.value in role_in or ROLE.ADMIN.value in role_in) + ) + + with patch(f"{TARGET_MODULE}.WorkspaceMember") as WM: + WM.objects = SimpleNamespace(filter=lambda **kw: wm_filter(**kw)) + res = wrapped(None, req, **kwargs_base) + + assert called["count"] == 1 + assert res == {"ok": True, "args": (), "kwargs": kwargs_base} + + +def test_project_level_allows_when_member_has_allowed_role(req, kwargs_base): + view, called = _mk_view() + # Mix enum and raw role integers to validate normalization + wrapped = allow_permission(allowed_roles=[ROLE.GUEST, ROLE.MEMBER.value], level="PROJECT")(view) + + def pm_filter(**kw): + role_in = kw.get("role__in", []) + return _stub_qs( + kw.get("member") is req.user + and kw.get("workspace__slug") == kwargs_base["slug"] + and kw.get("project_id") == kwargs_base["project_id"] + and kw.get("is_active") is True + and any(r in role_in for r in (ROLE.GUEST.value, ROLE.MEMBER.value)) + ) + + with patch(f"{TARGET_MODULE}.ProjectMember") as PM: + PM.objects = SimpleNamespace(filter=lambda **kw: pm_filter(**kw)) + + # WorkspaceMember path should not be consulted if project allowed returns True, + # but we patch it safe anyway. + with patch(f"{TARGET_MODULE}.WorkspaceMember") as WM: + WM.objects = SimpleNamespace(filter=lambda **kw: _stub_qs(False)) + res = wrapped(None, req, **kwargs_base) + + assert called["count"] == 1 + assert res == {"ok": True, "args": (), "kwargs": kwargs_base} + + +def test_project_level_allows_when_workspace_admin_and_in_project_even_if_role_not_allowed(req, kwargs_base): + view, called = _mk_view() + # Only allow GUEST; user will be MEMBER in project (not in allowed list) but ADMIN at workspace. + wrapped = allow_permission(allowed_roles=[ROLE.GUEST], level="PROJECT")(view) + + def pm_filter_role_check(**kw): + # First call checks role__in allowed — return False to go to admin override branch + role_in = kw.get("role__in") + if role_in is not None: + return _stub_qs(False) + # Second call in the 'elif' branch: checks just membership/is_active (no role__in) + return _stub_qs( + kw.get("member") is req.user + and kw.get("workspace__slug") == kwargs_base["slug"] + and kw.get("project_id") == kwargs_base["project_id"] + and kw.get("is_active") is True + ) + + def wm_filter_admin_check(**kw): + return _stub_qs( + kw.get("member") is req.user + and kw.get("workspace__slug") == kwargs_base["slug"] + and kw.get("is_active") is True + and kw.get("role") == ROLE.ADMIN.value + ) + + with patch(f"{TARGET_MODULE}.ProjectMember") as PM, patch(f"{TARGET_MODULE}.WorkspaceMember") as WM: + PM.objects = SimpleNamespace(filter=lambda **kw: pm_filter_role_check(**kw)) + WM.objects = SimpleNamespace(filter=lambda **kw: wm_filter_admin_check(**kw)) + + res = wrapped(None, req, **kwargs_base) + + assert called["count"] == 1, "Admin-in-workspace + member-in-project should grant access" + assert res == {"ok": True, "args": (), "kwargs": kwargs_base} + + +def test_denied_returns_403_with_message(req, kwargs_base): + view, called = _mk_view() + wrapped = allow_permission(allowed_roles=[ROLE.ADMIN], level="PROJECT")(view) + + with patch(f"{TARGET_MODULE}.WorkspaceMember") as WM, patch(f"{TARGET_MODULE}.ProjectMember") as PM: + WM.objects = SimpleNamespace(filter=lambda **kw: _stub_qs(False)) + PM.objects = SimpleNamespace(filter=lambda **kw: _stub_qs(False)) + + resp = wrapped(None, req, **kwargs_base) + + # When denied, the decorator returns a DRF Response; inspect essentials + # We avoid importing Response/status directly to keep the test isolated. + assert called["count"] == 0, "View must not be called on denied" + assert hasattr(resp, "status_code"), "Expected a DRF Response-like object" + assert int(resp.status_code) == 403 + # payload as dict with the expected message + data = getattr(resp, "data", None) or getattr(resp, "content", None) or {} + # DRF Response exposes 'data' + assert isinstance(getattr(resp, "data", {}), dict) + assert resp.data.get("error") == "You don't have the required permissions." + + +def test_creator_flag_ignored_when_model_not_provided(req, kwargs_base): + view, called = _mk_view() + wrapped = allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE", creator=True, model=None)(view) + + with patch(f"{TARGET_MODULE}.WorkspaceMember") as WM: + WM.objects = SimpleNamespace(filter=lambda **kw: _stub_qs(False)) + resp = wrapped(None, req, **kwargs_base) + + assert called["count"] == 0 + assert getattr(resp, "status_code", None) == 403 + assert resp.data.get("error") == "You don't have the required permissions." \ No newline at end of file diff --git a/apps/api/plane/tests/unit/app/permissions/test_project_permissions.py b/apps/api/plane/tests/unit/app/permissions/test_project_permissions.py new file mode 100644 index 00000000000..dbd343d462a --- /dev/null +++ b/apps/api/plane/tests/unit/app/permissions/test_project_permissions.py @@ -0,0 +1,299 @@ +""" +Tests for Project permission classes. + +Testing library and framework: +- Using pytest with pytest-django style (function tests + monkeypatch), and DRF permission classes are tested as pure functions on has_permission. +- We mock ORM queries (ProjectMember.objects.filter(...).exists() and WorkspaceMember.objects.filter(...).exists()) to isolate logic. + +Coverage strategy: +- ProjectBasePermission: anonymous, SAFE methods, POST create, non-safe methods with: project admin, project member + workspace admin, negative cases. +- ProjectMemberPermission: anonymous, SAFE methods, POST, non-safe update requiring role in [ADMIN, MEMBER]. +- ProjectEntityPermission: project_identifier path (SAFE), project_id path (SAFE), non-safe requiring role in [ADMIN, MEMBER]. +- ProjectLitePermission: membership present/absent. +""" + +import types +import pytest +from rest_framework.permissions import SAFE_METHODS +from apps.api.plane.tests.unit.app.permissions import test_project_permissions as _self # self-ref for context + +# Import permission classes under test +from plane.tests.unit.app.permissions.test_project_permissions import ( # type: ignore # When running in isolation, path will be local + ProjectBasePermission, + ProjectMemberPermission, + ProjectEntityPermission, + ProjectLitePermission, +) + +# Helpers to build a mock QuerySet-like object with .filter().exists() +class _QS: + def __init__(self, exists=False): + self._exists = exists + self._filters = [] + + def filter(self, **kwargs): + # Record filters to allow chaining assertions if needed + q = _QS(self._exists) + q._filters = self._filters + [kwargs] + return q + + def exists(self): + return bool(self._exists) + +@pytest.fixture +def mock_view(): + v = types.SimpleNamespace() + v.workspace_slug = "acme" + v.project_id = 42 + # Optional attribute for ProjectEntityPermission + v.project_identifier = None + return v + +@pytest.fixture +def user(): + class U: + is_anonymous = False + return U() + +@pytest.fixture +def anon_user(): + class U: + is_anonymous = True + return U() + +# Common monkeypatching of models' managers +@pytest.fixture +def mock_models(monkeypatch): + """ + Provides utilities to control return values of: + - WorkspaceMember.objects.filter(...).exists() + - ProjectMember.objects.filter(...).exists() + - And allows role-specific filters in ProjectMember chain. + """ + # State toggles configured per-test + state = { + "workspace_member_exists": False, + "project_member_exists": False, + "project_member_admin_exists": False, # For ProjectBasePermission admin check + "workspace_admin_exists": False, # For combined checks + "project_member_safe_exists": False, # For SAFE checks under ProjectEntity/Member + "project_identifier_safe_exists": False, + "post_allowed_workspace_roles": False, # For POST paths in Base/Member permissions + } + + # WorkspaceMember.objects.filter(...).exists() + class WMObjects: + def filter(self, **kwargs): + # POST checks in code gate on role__in [ROLE.ADMIN.value, ROLE.MEMBER.value] and is_active=True + # Non-POST check for Base SAFE uses membership active; combined admin check uses role=ROLE.ADMIN.value + # We simulate via flags: + # - post_allowed_workspace_roles: if True, POST permission path returns True + # - workspace_member_exists: general SAFE membership + # - workspace_admin_exists: admin for combined access + # We'll return corresponding exists based on presence of role filters + is_post_roles = ("role__in" in kwargs) + is_admin_role = (kwargs.get("role") is not None) + if is_post_roles: + return _QS(state["post_allowed_workspace_roles"]) + if is_admin_role: + return _QS(state["workspace_admin_exists"]) + return _QS(state["workspace_member_exists"]) + + class WorkspaceMemberModel: + objects = WMObjects() + + # ProjectMember.objects.filter(...).exists() + class PMObjects: + def filter(self, **kwargs): + # If role=ROLE.ADMIN.value present, return admin-specific flag + if kwargs.get("role") is not None: + return _QS(state["project_member_admin_exists"]) + # For SAFE in ProjectEntity/Member with identifier or project_id we can toggle with dedicated flags if given + if "project__identifier" in kwargs: + return _QS(state["project_identifier_safe_exists"]) + # For non-role filters, default to generic project member existence + return _QS(state["project_member_exists"]) + + class ProjectMemberModel: + objects = PMObjects() + + import builtins + + # Monkeypatch import path used in permission module + import plane.db.models as models_pkg + monkeypatch.setattr(models_pkg, "WorkspaceMember", WorkspaceMemberModel, raising=True) + monkeypatch.setattr(models_pkg, "ProjectMember", ProjectMemberModel, raising=True) + + # Also patch direct names if imported specifically (from plane.db.models import ProjectMember, WorkspaceMember) + monkeypatch.setitem(globals(), "WorkspaceMember", WorkspaceMemberModel) + monkeypatch.setitem(globals(), "ProjectMember", ProjectMemberModel) + + return state + +# -------- ProjectBasePermission tests -------- + +def test_project_base_permission_denies_anonymous(mock_view, anon_user): + perm = ProjectBasePermission() + req = types.SimpleNamespace(user=anon_user, method="GET") + assert perm.has_permission(req, mock_view) is False + +@pytest.mark.parametrize("safe_method", list(SAFE_METHODS)) +def test_project_base_permission_safe_methods_require_workspace_membership(mock_models, mock_view, user, safe_method): + perm = ProjectBasePermission() + req = types.SimpleNamespace(user=user, method=safe_method) + + # Not a member + mock_models["workspace_member_exists"] = False + assert perm.has_permission(req, mock_view) is False + + # Active member + mock_models["workspace_member_exists"] = True + assert perm.has_permission(req, mock_view) is True + +def test_project_base_permission_post_requires_workspace_admin_or_member(mock_models, mock_view, user): + perm = ProjectBasePermission() + req = types.SimpleNamespace(user=user, method="POST") + + # No proper role + mock_models["post_allowed_workspace_roles"] = False + assert perm.has_permission(req, mock_view) is False + + # Has allowed role (ROLE.ADMIN or ROLE.MEMBER) + mock_models["post_allowed_workspace_roles"] = True + assert perm.has_permission(req, mock_view) is True + +def test_project_base_permission_non_safe_allows_project_admin(mock_models, mock_view, user): + perm = ProjectBasePermission() + req = types.SimpleNamespace(user=user, method="PATCH") + + # Project admin: True regardless of workspace admin flag + mock_models["project_member_admin_exists"] = True + mock_models["project_member_exists"] = True + mock_models["workspace_admin_exists"] = False + assert perm.has_permission(req, mock_view) is True + +def test_project_base_permission_non_safe_allows_project_member_plus_workspace_admin(mock_models, mock_view, user): + perm = ProjectBasePermission() + req = types.SimpleNamespace(user=user, method="DELETE") + + # Not project admin, but is a project member and workspace admin + mock_models["project_member_admin_exists"] = False + mock_models["project_member_exists"] = True + mock_models["workspace_admin_exists"] = True + assert perm.has_permission(req, mock_view) is True + +def test_project_base_permission_non_safe_denies_without_required_combination(mock_models, mock_view, user): + perm = ProjectBasePermission() + req = types.SimpleNamespace(user=user, method="PUT") + + # Member but not workspace admin + mock_models["project_member_admin_exists"] = False + mock_models["project_member_exists"] = True + mock_models["workspace_admin_exists"] = False + assert perm.has_permission(req, mock_view) is False + + # Workspace admin but not a project member + mock_models["project_member_exists"] = False + mock_models["workspace_admin_exists"] = True + assert perm.has_permission(req, mock_view) is False + +# -------- ProjectMemberPermission tests -------- + +def test_project_member_permission_denies_anonymous(mock_view, anon_user): + perm = ProjectMemberPermission() + req = types.SimpleNamespace(user=anon_user, method="GET") + assert perm.has_permission(req, mock_view) is False + +@pytest.mark.parametrize("safe_method", list(SAFE_METHODS)) +def test_project_member_permission_safe_requires_project_membership(mock_models, mock_view, user, safe_method): + perm = ProjectMemberPermission() + req = types.SimpleNamespace(user=user, method=safe_method) + + # Not a project member + mock_models["project_member_exists"] = False + assert perm.has_permission(req, mock_view) is False + + # Is a project member + mock_models["project_member_exists"] = True + assert perm.has_permission(req, mock_view) is True + +def test_project_member_permission_post_requires_workspace_role(mock_models, mock_view, user): + perm = ProjectMemberPermission() + req = types.SimpleNamespace(user=user, method="POST") + + mock_models["post_allowed_workspace_roles"] = False + assert perm.has_permission(req, mock_view) is False + + mock_models["post_allowed_workspace_roles"] = True + assert perm.has_permission(req, mock_view) is True + +def test_project_member_permission_non_safe_requires_role_admin_or_member(mock_models, mock_view, user): + perm = ProjectMemberPermission() + req = types.SimpleNamespace(user=user, method="PATCH") + + # No membership + mock_models["project_member_exists"] = False + assert perm.has_permission(req, mock_view) is False + + # Has role in [ADMIN, MEMBER] (simulated via existence True) + mock_models["project_member_exists"] = True + assert perm.has_permission(req, mock_view) is True + +# -------- ProjectEntityPermission tests -------- + +def test_project_entity_permission_denies_anonymous(mock_view, anon_user): + perm = ProjectEntityPermission() + req = types.SimpleNamespace(user=anon_user, method="GET") + assert perm.has_permission(req, mock_view) is False + +def test_project_entity_permission_safe_with_project_identifier(mock_models, mock_view, user): + perm = ProjectEntityPermission() + mock_view.project_identifier = "PRJ" + req = types.SimpleNamespace(user=user, method="GET") + + mock_models["project_identifier_safe_exists"] = False + assert perm.has_permission(req, mock_view) is False + + mock_models["project_identifier_safe_exists"] = True + assert perm.has_permission(req, mock_view) is True + +@pytest.mark.parametrize("safe_method", list(SAFE_METHODS)) +def test_project_entity_permission_safe_with_project_id(mock_models, mock_view, user, safe_method): + perm = ProjectEntityPermission() + mock_view.project_identifier = None + req = types.SimpleNamespace(user=user, method=safe_method) + + mock_models["project_member_exists"] = False + assert perm.has_permission(req, mock_view) is False + + mock_models["project_member_exists"] = True + assert perm.has_permission(req, mock_view) is True + +def test_project_entity_permission_non_safe_requires_role_admin_or_member(mock_models, mock_view, user): + perm = ProjectEntityPermission() + req = types.SimpleNamespace(user=user, method="POST") # Create entity under project + + # Not in allowed roles + mock_models["project_member_exists"] = False + assert perm.has_permission(req, mock_view) is False + + # In allowed roles + mock_models["project_member_exists"] = True + assert perm.has_permission(req, mock_view) is True + +# -------- ProjectLitePermission tests -------- + +def test_project_lite_permission_denies_anonymous(mock_view, anon_user): + perm = ProjectLitePermission() + req = types.SimpleNamespace(user=anon_user, method="GET") + assert perm.has_permission(req, mock_view) is False + +def test_project_lite_permission_requires_project_membership(mock_models, mock_view, user): + perm = ProjectLitePermission() + req = types.SimpleNamespace(user=user, method="GET") + + mock_models["project_member_exists"] = False + assert perm.has_permission(req, mock_view) is False + + mock_models["project_member_exists"] = True + assert perm.has_permission(req, mock_view) is True \ No newline at end of file diff --git a/apps/api/plane/tests/unit/app/views/project/test_base_view.py b/apps/api/plane/tests/unit/app/views/project/test_base_view.py new file mode 100644 index 00000000000..4ec41a852bd --- /dev/null +++ b/apps/api/plane/tests/unit/app/views/project/test_base_view.py @@ -0,0 +1,577 @@ +# NOTE: Testing library/framework in use: +# - pytest with pytest-django +# - Django REST Framework test utilities (APIClient) +# Conform to repository's existing pytest style and markers. +import pytest + +pytestmark = pytest.mark.unit + +import json +from datetime import datetime, timedelta + +from unittest.mock import patch, MagicMock + +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory, APIClient + +# Import the view classes under test for direct invocation with APIRequestFactory +try: + from apps.api.plane.app.views.project.base import ( + ProjectViewSet, + ProjectArchiveUnarchiveEndpoint, + ProjectIdentifierEndpoint, + ProjectUserViewsEndpoint, + ProjectFavoritesViewSet, + ProjectPublicCoverImagesEndpoint, + DeployBoardViewSet, + ) +except Exception: + # Fallback import path if the module name differs + from apps.api.plane.app.views.project import base as base_view # type: ignore + ProjectViewSet = getattr(base_view, "ProjectViewSet") + ProjectArchiveUnarchiveEndpoint = getattr(base_view, "ProjectArchiveUnarchiveEndpoint") + ProjectIdentifierEndpoint = getattr(base_view, "ProjectIdentifierEndpoint") + ProjectUserViewsEndpoint = getattr(base_view, "ProjectUserViewsEndpoint") + ProjectFavoritesViewSet = getattr(base_view, "ProjectFavoritesViewSet") + ProjectPublicCoverImagesEndpoint = getattr(base_view, "ProjectPublicCoverImagesEndpoint") + DeployBoardViewSet = getattr(base_view, "DeployBoardViewSet") + + +@pytest.fixture +def api_rf(): + return APIRequestFactory() + + +@pytest.fixture +def api_client(db, django_user_model): + user = django_user_model.objects.create_user( + username="tester", email="tester@example.com", password="password" + ) + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def authed_user(db, django_user_model): + return django_user_model.objects.create_user( + username="tester2", email="tester2@example.com", password="password" + ) + + +# Identity serializer used to bypass real serialization logic +class _IdentitySerializer: + def __init__(self, instance=None, many=False, fields=None, context=None, data=None, partial=False): + self.instance = instance + self.many = many + self.fields = fields + self.context = context or {} + self._data = data + self.partial = partial + + def is_valid(self): + return True + + @property + def data(self): + if self._data is not None and self.instance is None: + return self._data + if self.many and hasattr(self.instance, "__iter__"): + return list(self.instance) + return self.instance + + def save(self, **kwargs): + if isinstance(self._data, dict): + self._data.setdefault("id", 1) + return self + + +# ---------- ProjectViewSet tests ---------- + +@pytest.mark.django_db +def test_project_viewset_list_detail_basic_filters_and_fields(api_rf, authed_user): + view = ProjectViewSet.as_view({"get": "list_detail"}) + req = api_rf.get("/workspaces/acme/projects?fields=id,name") + req.user = authed_user + + projects_list = [ + {"id": 2, "name": "Beta", "sort_order": 1}, + {"id": 1, "name": "Alpha", "sort_order": 0}, + ] + + with patch("apps.api.plane.app.views.project.base.ProjectViewSet.get_queryset") as get_qs, \ + patch("apps.api.plane.app.views.project.base.ProjectListSerializer", _IdentitySerializer), \ + patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM: + get_qs.return_value = MagicMock(order_by=MagicMock(return_value=projects_list)) + WM.objects.filter().exists.return_value = False + + resp = view(req, slug="acme") + + assert resp.status_code == status.HTTP_200_OK + assert resp.data == projects_list + + +@pytest.mark.django_db +def test_project_viewset_list_detail_guest_filter_applied(api_rf, authed_user): + view = ProjectViewSet.as_view({"get": "list_detail"}) + req = api_rf.get("/workspaces/acme/projects") + req.user = authed_user + + filtered_qs = [{"id": 7, "name": "Guest Only"}] + + class _OrderedQS: + def __init__(self, data): + self.data = data + def order_by(self, *args): + return self.data + def filter(self, *args, **kwargs): + return filtered_qs + + with patch("apps.api.plane.app.views.project.base.ProjectViewSet.get_queryset") as get_qs, \ + patch("apps.api.plane.app.views.project.base.ProjectListSerializer", _IdentitySerializer), \ + patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM: + get_qs.return_value = _OrderedQS(data=[{"id": 1}, {"id": 2}]) + WM.objects.filter().exists.side_effect = [True, False] # guest True, member False + resp = view(req, slug="acme") + + assert resp.status_code == status.HTTP_200_OK + assert resp.data == filtered_qs + + +@pytest.mark.django_db +def test_project_viewset_list_pagination_branch(api_rf, authed_user): + view = ProjectViewSet.as_view({"get": "list_detail"}) + req = api_rf.get("/workspaces/acme/projects?per_page=10&cursor=abc&order_by=name") + req.user = authed_user + + paginated_payload = [{"id": 10}, {"id": 11}] + + with patch("apps.api.plane.app.views.project.base.ProjectViewSet.get_queryset") as get_qs, \ + patch("apps.api.plane.app.views.project.base.ProjectListSerializer", _IdentitySerializer), \ + patch.object(ProjectViewSet, "paginate", side_effect=lambda **kw: Response(paginated_payload, status=status.HTTP_200_OK)), \ + patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM: + get_qs.return_value = MagicMock(order_by=MagicMock(return_value=[{"id": 1}])) + WM.objects.filter().exists.return_value = False + resp = view(req, slug="acme") + + assert resp.status_code == status.HTTP_200_OK + assert resp.data == paginated_payload + + +@pytest.mark.django_db +def test_project_viewset_list_values_subset_and_member_filter(api_rf, authed_user): + view = ProjectViewSet.as_view({"get": "list"}) + req = api_rf.get("/workspaces/acme/projects") + req.user = authed_user + + values_payload = [{"id": 1, "name": "A", "member_role": "admin"}] + + class _QS: + def filter(self, *args, **kwargs): return self + def select_related(self, *args, **kwargs): return self + def annotate(self, *args, **kwargs): return self + def distinct(self): return self + def values(self, *args, **kwargs): return values_payload + + with patch("apps.api.plane.app.views.project.base.Project") as Project, \ + patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM: + Project.objects.filter.return_value = _QS() + WM.objects.filter().exists.side_effect = [False, True] # member branch triggers + resp = view(req, slug="acme") + + assert resp.status_code == status.HTTP_200_OK + assert resp.data == values_payload + + +@pytest.mark.django_db +def test_project_viewset_retrieve_success_and_task_trigger(api_rf, authed_user): + view = ProjectViewSet.as_view({"get": "retrieve"}) + req = api_rf.get("/workspaces/acme/projects/1") + req.user = authed_user + + project_obj = {"id": 1, "name": "Proj"} + + with patch("apps.api.plane.app.views.project.base.ProjectViewSet.get_queryset") as get_qs, \ + patch("apps.api.plane.app.views.project.base.ProjectListSerializer", _IdentitySerializer), \ + patch("apps.api.plane.app.views.project.base.recent_visited_task") as recent: + get_qs.return_value = MagicMock( + filter=MagicMock(return_value=MagicMock(first=MagicMock(return_value=project_obj))) + ) + resp = view(req, slug="acme", pk=1) + + assert resp.status_code == status.HTTP_200_OK + assert resp.data == project_obj + + +@pytest.mark.django_db +def test_project_viewset_retrieve_not_found(api_rf, authed_user): + view = ProjectViewSet.as_view({"get": "retrieve"}) + req = api_rf.get("/workspaces/acme/projects/999") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.ProjectViewSet.get_queryset") as get_qs: + get_qs.return_value = MagicMock( + filter=MagicMock(return_value=MagicMock(first=MagicMock(return_value=None))) + ) + resp = view(req, slug="acme", pk=999) + + assert resp.status_code == status.HTTP_404_NOT_FOUND + assert resp.data["error"] == "Project does not exist" + + +@pytest.mark.django_db +def test_project_viewset_create_creates_members_states_and_activity(api_rf, authed_user): + view = ProjectViewSet.as_view({"post": "create"}) + req = api_rf.post("/workspaces/acme/projects", data={"name": "N", "project_lead": None}, format="json") + req.user = authed_user + + saved_instance = {"id": 123, "project_lead": None} + + with patch("apps.api.plane.app.views.project.base.Workspace") as Workspace, \ + patch("apps.api.plane.app.views.project.base.ProjectSerializer") as PSer, \ + patch("apps.api.plane.app.views.project.base.ProjectMember") as PMember, \ + patch("apps.api.plane.app.views.project.base.IssueUserProperty") as IUP, \ + patch("apps.api.plane.app.views.project.base.State") as State, \ + patch("apps.api.plane.app.views.project.base.Project") as Project, \ + patch("apps.api.plane.app.views.project.base.ProjectListSerializer", _IdentitySerializer), \ + patch("apps.api.plane.app.views.project.base.model_activity") as model_activity_task: + Workspace.objects.get.return_value = MagicMock(id=1) + serializer_instance = MagicMock() + serializer_instance.is_valid.return_value = True + serializer_instance.data = saved_instance + serializer_instance.instance = MagicMock() + serializer_instance.save.return_value = None + PSer.return_value = serializer_instance + ProjectViewSet.get_queryset = MagicMock( + return_value=MagicMock( + filter=MagicMock(return_value=MagicMock(first=MagicMock(return_value=saved_instance))) + ) + ) + resp = view(req, slug="acme") + + assert resp.status_code == status.HTTP_201_CREATED + assert resp.data["id"] == 123 + + +@pytest.mark.django_db +def test_project_viewset_partial_update_permission_denied(api_rf, authed_user): + view = ProjectViewSet.as_view({"patch": "partial_update"}) + req = api_rf.patch("/workspaces/acme/projects/1", data={"name": "New"}, format="json") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM, \ + patch("apps.api.plane.app.views.project.base.ProjectMember") as PM: + WM.objects.filter().exists.return_value = False + PM.objects.filter().exists.return_value = False + resp = view(req, slug="acme", pk=1) + + assert resp.status_code == status.HTTP_403_FORBIDDEN + assert "required permissions" in resp.data["error"] + + +@pytest.mark.django_db +def test_project_viewset_partial_update_archived_blocked(api_rf, authed_user): + view = ProjectViewSet.as_view({"patch": "partial_update"}) + req = api_rf.patch("/workspaces/acme/projects/1", data={"name": "New"}, format="json") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM, \ + patch("apps.api.plane.app.views.project.base.ProjectMember") as PM, \ + patch("apps.api.plane.app.views.project.base.Workspace") as Workspace, \ + patch("apps.api.plane.app.views.project.base.Project") as Project: + WM.objects.filter().exists.return_value = True + PM.objects.filter().exists.return_value = False + Workspace.objects.get.return_value = MagicMock(id=1) + proj = MagicMock(archived_at=timezone.now(), intake_view=False) + Project.objects.get.return_value = proj + + resp = view(req, slug="acme", pk=1) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "Archived projects cannot be updated" in resp.data["error"] + + +@pytest.mark.django_db +def test_project_viewset_destroy_admin_deletes_and_webhook(api_rf, authed_user): + view = ProjectViewSet.as_view({"delete": "destroy"}) + req = api_rf.delete("/workspaces/acme/projects/5") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM, \ + patch("apps.api.plane.app.views.project.base.ProjectMember") as PM, \ + patch("apps.api.plane.app.views.project.base.Project") as Project, \ + patch("apps.api.plane.app.views.project.base.webhook_activity") as webhook, \ + patch("apps.api.plane.app.views.project.base.DeployBoard") as DeployBoard, \ + patch("apps.api.plane.app.views.project.base.UserFavorite") as UserFavorite: + WM.objects.filter().exists.return_value = True + PM.objects.filter().exists.return_value = False + Project.objects.get.return_value = MagicMock(id=5) + + resp = view(req, slug="acme", pk=5) + + assert resp.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_project_viewset_destroy_forbidden(api_rf, authed_user): + view = ProjectViewSet.as_view({"delete": "destroy"}) + req = api_rf.delete("/workspaces/acme/projects/5") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.WorkspaceMember") as WM, \ + patch("apps.api.plane.app.views.project.base.ProjectMember") as PM: + WM.objects.filter().exists.return_value = False + PM.objects.filter().exists.return_value = False + resp = view(req, slug="acme", pk=5) + + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +# ---------- ProjectArchiveUnarchiveEndpoint ---------- + +@pytest.mark.django_db +def test_project_archive_endpoint_posts_and_clears_favorites(api_rf, authed_user): + view = ProjectArchiveUnarchiveEndpoint.as_view() + req = api_rf.post("/workspaces/acme/projects/1/archive") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.Project") as Project, \ + patch("apps.api.plane.app.views.project.base.UserFavorite") as UF: + proj = MagicMock(archived_at=None) + Project.objects.get.return_value = proj + UF.objects.filter.return_value.delete.return_value = None + + resp = view(req, slug="acme", project_id=1) + + assert resp.status_code == status.HTTP_200_OK + assert "archived_at" in resp.data + + +@pytest.mark.django_db +def test_project_unarchive_endpoint_deletes_returns_204(api_rf, authed_user): + view = ProjectArchiveUnarchiveEndpoint.as_view() + req = api_rf.delete("/workspaces/acme/projects/1/archive") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.Project") as Project: + proj = MagicMock(archived_at=timezone.now()) + Project.objects.get.return_value = proj + + resp = view(req, slug="acme", project_id=1) + + assert resp.status_code == status.HTTP_204_NO_CONTENT + + +# ---------- ProjectIdentifierEndpoint ---------- + +@pytest.mark.django_db +def test_project_identifier_get_requires_name_and_returns_results(api_rf, authed_user): + view = ProjectIdentifierEndpoint.as_view() + + # Missing name -> 400 + req = api_rf.get("/workspaces/acme/projects/identifier") + req.user = authed_user + resp = view(req, slug="acme") + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "Name is required" in resp.data["error"] + + # With name -> returns exists count and identifiers list + req2 = api_rf.get("/workspaces/acme/projects/identifier?name= PX ") + req2.user = authed_user + with patch("apps.api.plane.app.views.project.base.ProjectIdentifier") as PI: + PI.objects.filter().values.return_value = [{"id": 1, "name": "PX", "project": None}] + resp2 = view(req2, slug="acme") + assert resp2.status_code == status.HTTP_200_OK + assert resp2.data["exists"] == 1 + assert resp2.data["identifiers"][0]["name"] == "PX" + + +@pytest.mark.django_db +def test_project_identifier_delete_validations(api_rf, authed_user): + view = ProjectIdentifierEndpoint.as_view() + + # Missing name -> 400 + req = api_rf.delete("/workspaces/acme/projects/identifier", data={}, format="json") + req.user = authed_user + resp = view(req, slug="acme") + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + # Name exists on a Project -> cannot delete + req2 = api_rf.delete("/workspaces/acme/projects/identifier", data={"name": "PX"}, format="json") + req2.user = authed_user + with patch("apps.api.plane.app.views.project.base.Project") as Project: + Project.objects.filter().exists.return_value = True + resp2 = view(req2, slug="acme") + assert resp2.status_code == status.HTTP_400_BAD_REQUEST + + # Otherwise deletes -> 204 + req3 = api_rf.delete("/workspaces/acme/projects/identifier", data={"name": "PX"}, format="json") + req3.user = authed_user + with patch("apps.api.plane.app.views.project.base.Project") as Project, \ + patch("apps.api.plane.app.views.project.base.ProjectIdentifier") as PI: + Project.objects.filter().exists.return_value = False + resp3 = view(req3, slug="acme") + assert resp3.status_code == status.HTTP_204_NO_CONTENT + + +# ---------- ProjectUserViewsEndpoint ---------- + +@pytest.mark.django_db +def test_project_user_views_updates_member_props(api_rf, authed_user): + view = ProjectUserViewsEndpoint.as_view() + req = api_rf.post( + "/workspaces/acme/projects/1/user-views", + data={"view_props": {"foo": "bar"}, "sort_order": 9}, + format="json", + ) + req.user = authed_user + + member = MagicMock(view_props={"x": 1}, default_props={"y": 2}, preferences={"z": 3}, sort_order=1) + + with patch("apps.api.plane.app.views.project.base.Project") as Project, \ + patch("apps.api.plane.app.views.project.base.ProjectMember") as PM: + Project.objects.get.return_value = MagicMock() + PM.objects.filter().first.return_value = member + + resp = view(req, slug="acme", project_id=1) + + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert member.view_props == {"foo": "bar"} + assert member.sort_order == 9 + + +@pytest.mark.django_db +def test_project_user_views_forbidden_when_not_member(api_rf, authed_user): + view = ProjectUserViewsEndpoint.as_view() + req = api_rf.post("/workspaces/acme/projects/1/user-views", data={}, format="json") + req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.Project") as Project, \ + patch("apps.api.plane.app.views.project.base.ProjectMember") as PM: + Project.objects.get.return_value = MagicMock() + PM.objects.filter().first.return_value = None + + resp = view(req, slug="acme", project_id=1) + + assert resp.status_code == status.HTTP_403_FORBIDDEN + assert resp.data["error"] == "Forbidden" + + +# ---------- ProjectFavoritesViewSet ---------- + +@pytest.mark.django_db +def test_project_favorite_create_and_destroy(api_rf, authed_user): + create_view = ProjectFavoritesViewSet.as_view({"post": "create"}) + delete_view = ProjectFavoritesViewSet.as_view({"delete": "destroy"}) + + create_req = api_rf.post("/workspaces/acme/projects/favorites", data={"project": 42}, format="json") + create_req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.UserFavorite") as UF: + UF.objects.create.return_value = MagicMock() + resp = create_view(create_req, slug="acme") + assert resp.status_code == status.HTTP_204_NO_CONTENT + + delete_req = api_rf.delete("/workspaces/acme/projects/favorites/42") + delete_req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.UserFavorite") as UF: + fav = MagicMock() + UF.objects.get.return_value = fav + resp2 = delete_view(delete_req, slug="acme", project_id=42) + assert resp2.status_code == status.HTTP_204_NO_CONTENT + assert fav.delete.called + + +# ---------- ProjectPublicCoverImagesEndpoint ---------- + +@pytest.mark.django_db +def test_public_cover_images_lists_files_success(api_rf): + view = ProjectPublicCoverImagesEndpoint.as_view() + req = api_rf.get("/projects/public-cover-images") + + with patch("apps.api.plane.app.views.project.base.settings") as settings_mod, \ + patch("apps.api.plane.app.views.project.base.boto3") as boto3_mod: + settings_mod.USE_MINIO = False + settings_mod.AWS_STORAGE_BUCKET_NAME = "bucket" + settings_mod.AWS_REGION = "us-east-1" + settings_mod.AWS_ACCESS_KEY_ID = "x" + settings_mod.AWS_SECRET_ACCESS_KEY = "y" + + client = MagicMock() + boto3_mod.client.return_value = client + client.list_objects_v2.return_value = { + "Contents": [ + {"Key": "static/project-cover/img1.jpg"}, + {"Key": "static/project-cover/subdir/"}, # folder-like, should be ignored + ] + } + + resp = view(req) + + assert resp.status_code == status.HTTP_200_OK + assert resp.data == ["https://bucket.s3.us-east-1.amazonaws.com/static/project-cover/img1.jpg"] + + +@pytest.mark.django_db +def test_public_cover_images_handles_exception_and_returns_empty(api_rf): + view = ProjectPublicCoverImagesEndpoint.as_view() + req = api_rf.get("/projects/public-cover-images") + + with patch("apps.api.plane.app.views.project.base.settings") as settings_mod, \ + patch("apps.api.plane.app.views.project.base.boto3") as boto3_mod: + settings_mod.USE_MINIO = True + settings_mod.AWS_S3_ENDPOINT_URL = "http://minio" + settings_mod.AWS_STORAGE_BUCKET_NAME = "bucket" + settings_mod.AWS_REGION = "us-east-1" + settings_mod.AWS_ACCESS_KEY_ID = "x" + settings_mod.AWS_SECRET_ACCESS_KEY = "y" + + boto3_mod.client.side_effect = Exception("boom") + with patch("apps.api.plane.app.views.project.base.log_exception") as log_exc: + resp = view(req) + + assert resp.status_code == status.HTTP_200_OK + assert resp.data == [] + + +# ---------- DeployBoardViewSet ---------- + +@pytest.mark.django_db +def test_deploy_board_list_and_create(api_rf, authed_user): + list_view = DeployBoardViewSet.as_view({"get": "list"}) + create_view = DeployBoardViewSet.as_view({"post": "create"}) + + # list + list_req = api_rf.get("/workspaces/acme/projects/1/deploy-board") + list_req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.DeployBoard") as DB, \ + patch("apps.api.plane.app.views.project.base.DeployBoardSerializer", _IdentitySerializer): + DB.objects.filter.return_value.first.return_value = {"id": 1, "entity_identifier": 1} + resp = list_view(list_req, slug="acme", project_id=1) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["id"] == 1 + + # create + create_req = api_rf.post( + "/workspaces/acme/projects/1/deploy-board", + data={ + "is_comments_enabled": True, + "is_votes_enabled": True, + "views": {"list": True, "gantt": False}, + "intake": 5, + }, + format="json", + ) + create_req.user = authed_user + + with patch("apps.api.plane.app.views.project.base.DeployBoard") as DB, \ + patch("apps.api.plane.app.views.project.base.DeployBoardSerializer", _IdentitySerializer): + instance = MagicMock() + DB.objects.get_or_create.return_value = (instance, True) + resp2 = create_view(create_req, slug="acme", project_id=1) + + assert resp2.status_code == status.HTTP_200_OK \ No newline at end of file diff --git a/apps/api/plane/tests/unit/db/models/test_project_model.py b/apps/api/plane/tests/unit/db/models/test_project_model.py new file mode 100644 index 00000000000..16959adbcb6 --- /dev/null +++ b/apps/api/plane/tests/unit/db/models/test_project_model.py @@ -0,0 +1,238 @@ +import re +import uuid +import pytest +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.utils import timezone + +# Import models under test. Adjust import path if different in this repo. +# We keep relative import flexible by trying common module paths. +try: + from plane.db.models.project import ( + Project, + ProjectMember, + ProjectMemberInvite, + ProjectDeployBoard, + ProjectPublicMember, + ProjectBaseModel, + ProjectNetwork, + get_default_props, + get_default_preferences, + get_default_views, + ) +except Exception: # pragma: no cover - fallback for alternate layout + from apps.api.plane.db.models.project import ( # type: ignore + Project, + ProjectMember, + ProjectMemberInvite, + ProjectDeployBoard, + ProjectPublicMember, + ProjectBaseModel, + ProjectNetwork, + get_default_props, + get_default_preferences, + get_default_views, + ) + +# Related FK models (best-effort minimal imports; adjust path if needed) +try: + from plane.db.models import Workspace, WorkSpace, FileAsset, Estimate, State, Intake # type: ignore +except Exception: # pragma: no cover + # Some repos use different capitalization for WorkSpace/Workspace + Workspace = None + WorkSpace = None + FileAsset = None + Estimate = None + State = None + Intake = None + + +pytestmark = pytest.mark.django_db + + +def make_workspace(name="Acme"): + """ + Create a minimal Workspace/WorkSpace instance, handling either class name. + """ + Model = Workspace or WorkSpace + assert Model is not None, "Workspace/WorkSpace model not found in import path." + # Try to identify minimal required fields: name is common; fallback to dynamic kwargs + kwargs = {} + if hasattr(Model, "_meta") and any(f.name == "name" and not f.many_to_many for f in Model._meta.get_fields()): + kwargs["name"] = name + if hasattr(Model, "_meta") and any(f.name == "slug" for f in Model._meta.get_fields()): + kwargs.setdefault("slug", name.lower()) + if hasattr(Model, "_meta") and any(f.name == "organization" for f in Model._meta.get_fields()): + # Some schemas require organization FK; create a dummy if needed + # Avoid importing org model; instead, try nullable or set later + pass + return Model.objects.create(**kwargs) + + +def make_user(email="user@example.com"): + User = get_user_model() + # Ensure unique email/username if required by schema + i = uuid.uuid4().hex[:6] + base_email = email.split("@")[0] + e = f"{base_email}+{i}@example.com" + # Handle username if required by model + fields = {"email": e} + if hasattr(User, "USERNAME_FIELD") and User.USERNAME_FIELD \!= "email": + # supply username if needed + fields.setdefault("username", f"user_{i}") + # Provide mandatory defaults if necessary + for field in ("first_name", "last_name"): + if any(getattr(f, "name", "") == field and not getattr(f, "blank", True) for f in User._meta.get_fields()): + fields[field] = field + user = User.objects.create(**fields) + return user + + +def test_project_cover_image_url_prefers_asset_over_text(db): + ws = make_workspace() + proj = Project.objects.create( + name="Proj", + workspace=ws, + identifier="ac", + ) + # No cover set + assert proj.cover_image_url is None + + # Set text cover + proj.cover_image = "https://cdn.example.com/covers/1.jpg" + assert proj.cover_image_url == "https://cdn.example.com/covers/1.jpg" + + # If FileAsset exists, it should take precedence + if FileAsset is not None: + asset = FileAsset.objects.create(asset_url="https://assets.example.com/a.png") # type: ignore + proj.cover_image_asset = asset + assert proj.cover_image_url == "https://assets.example.com/a.png" + + +def test_project_str_includes_name_and_workspace(db): + ws = make_workspace(name="Team X") + proj = Project.objects.create(name="Roadmap", workspace=ws, identifier="rx") + assert str(proj) == "Roadmap " + + +def test_project_save_normalizes_identifier_strip_and_upper(db): + ws = make_workspace() + proj = Project.objects.create(name="Normalize", workspace=ws, identifier=" prj-1 ") + # Save hook should strip and upper + proj.refresh_from_db() + assert proj.identifier == "PRJ-1" + + +def test_project_timezone_defaults_to_utc_and_choices_include_utc(db): + ws = make_workspace() + proj = Project.objects.create(name="TZ", workspace=ws, identifier="tz1") + assert proj.timezone == "UTC" + # Validate "UTC" present in choices + choices = dict(Project.TIMEZONE_CHOICES) + assert "UTC" in choices and choices["UTC"] == "UTC" + + +def test_project_base_model_save_sets_workspace_from_project(db): + # Define a minimal subclass to exercise ProjectBaseModel.save + ws = make_workspace() + proj = Project.objects.create(name="Base", workspace=ws, identifier="b1") + + class DummyPBM(ProjectBaseModel): + # ephemeral model; Django won't create DB table for abstract parent, + # so we simulate behavior by instantiating a derived in-memory object. + class Meta: + abstract = True + + # Instead, use a concrete ProjectMemberInvite which inherits ProjectBaseModel + inv = ProjectMemberInvite.objects.create(project=proj, workspace=None, email="i@example.com", token="t") + inv.refresh_from_db() + assert inv.workspace_id == proj.workspace_id + + +def test_project_member_initial_sort_order_logic(db): + ws = make_workspace() + user = make_user() + proj1 = Project.objects.create(name="A", workspace=ws, identifier="A") + proj2 = Project.objects.create(name="B", workspace=ws, identifier="B") + + # First membership for user: default sort_order remains 65535 (no prior records) + pm1 = ProjectMember.objects.create(project=proj1, workspace=ws, member=user) + assert pm1.sort_order == pytest.approx(65535) + + # Second membership for same user in same workspace: should be smallest - 10000 + pm2 = ProjectMember.objects.create(project=proj2, workspace=ws, member=user) + assert pm2.sort_order == pytest.approx(pm1.sort_order - 10000) + + +def test_project_member_str_uses_member_email_and_project_name(db): + ws = make_workspace() + user = make_user(email="m@example.com") + proj = Project.objects.create(name="Alpha", workspace=ws, identifier="ALP") + pm = ProjectMember.objects.create(project=proj, workspace=ws, member=user) + assert str(pm) == f"{user.email} " + + +def test_project_member_unique_constraint_enforced(db): + ws = make_workspace() + user = make_user() + proj = Project.objects.create(name="U", workspace=ws, identifier="U") + ProjectMember.objects.create(project=proj, workspace=ws, member=user) + with pytest.raises(IntegrityError): + with transaction.atomic(): + ProjectMember.objects.create(project=proj, workspace=ws, member=user) + + +def test_project_member_invite_str(db): + ws = make_workspace() + proj = Project.objects.create(name="InviteProj", workspace=ws, identifier="IP") + inv = ProjectMemberInvite.objects.create(project=proj, workspace=ws, email="x@example.com", token="tok") + assert str(inv) == f"{proj.name} {inv.email} {inv.accepted}" + + +def test_project_deploy_board_anchor_default_and_str(db): + ws = make_workspace() + proj = Project.objects.create(name="Deploy", workspace=ws, identifier="D") + pdb = ProjectDeployBoard.objects.create(project=proj, workspace=ws) + assert re.fullmatch(r"[0-9a-f]{32}", pdb.anchor), "anchor should be uuid4 hex" + assert str(pdb) == f"{pdb.anchor} <{proj.name}>" + + +def test_project_public_member_uniqueness(db): + if ProjectPublicMember is None: + pytest.skip("ProjectPublicMember not available") + ws = make_workspace() + user = make_user() + proj = Project.objects.create(name="Pub", workspace=ws, identifier="PUB") + ProjectPublicMember.objects.create(project=proj, workspace=ws, member=user) + with pytest.raises(IntegrityError): + with transaction.atomic(): + ProjectPublicMember.objects.create(project=proj, workspace=ws, member=user) + + +def test_default_helpers_return_expected_shapes(): + props = get_default_props() + assert "filters" in props and "display_filters" in props + assert isinstance(props["filters"], dict) + assert isinstance(props["display_filters"], dict) + # Ensure the function returns a new dict (not shared reference) + props2 = get_default_props() + assert props is not props2 + + prefs = get_default_preferences() + assert "pages" in prefs and isinstance(prefs["pages"], dict) + + views = get_default_views() + assert views == { + "list": True, + "kanban": True, + "calendar": True, + "gantt": True, + "spreadsheet": True, + } + + +def test_project_network_choices_matches_enum(): + assert ProjectNetwork.choices() == [(0, "Secret"), (2, "Public")] + # Ensure values map as expected + assert ProjectNetwork.SECRET.value == 0 + assert ProjectNetwork.PUBLIC.value == 2 \ No newline at end of file diff --git a/apps/web/core/store/user/__tests__/base-permissions.store.spec.ts b/apps/web/core/store/user/__tests__/base-permissions.store.spec.ts new file mode 100644 index 00000000000..e4086274f72 --- /dev/null +++ b/apps/web/core/store/user/__tests__/base-permissions.store.spec.ts @@ -0,0 +1,433 @@ +/* + Test Suite: BaseUserPermissionStore + Framework: Vitest (vi/describe/it/expect). If the project uses Jest, replace vi with jest. + + We comprehensively cover: + - computed helpers: workspaceInfoBySlug, getWorkspaceRoleByWorkspaceSlug, getProjectRolesByWorkspaceSlug, hasPageAccess + - action helpers: allowPermissions (workspace + project), parseInt handling, onPermissionAllowed callback behavior + - actions: fetchUserWorkspaceInfo, leaveWorkspace, fetchUserProjectInfo, fetchUserProjectPermissions, joinProject, leaveProject + - ADMIN escalation from workspace role to project role + - router fallbacks for workspaceSlug/projectId + - edge cases: missing inputs, unknown keys, errors + + External dependencies are mocked: + - @/plane-web/services/workspace.service + - @/services/project/project-member.service + - @/services/user.service + - @plane/constants (permissions enums + sidebar links) + - @plane/types (minimal shapes used as 'any' in tests) +*/ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock constants used by the store +vi.mock("@plane/constants", () => { + const EUserPermissions = { + VIEWER: 1, + MEMBER: 2, + ADMIN: 3, + } as const; + + const EUserPermissionsLevel = { + WORKSPACE: "WORKSPACE", + PROJECT: "PROJECT", + } as const; + + // Keep keys small and deterministic for hasPageAccess tests + const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS = [ + { key: "dashboard", access: [EUserPermissions.MEMBER, EUserPermissions.ADMIN] }, + { key: "settings", access: [EUserPermissions.ADMIN] }, + ]; + + return { + EUserPermissions, + EUserPermissionsLevel, + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, + }; +}); + +// Mock minimal @plane/types exports used by the store (we only need role shapes) +vi.mock("@plane/types", () => { + const EUserWorkspaceRoles = { + ADMIN: "ADMIN", + MEMBER: "MEMBER", + GUEST: "GUEST", + } as const; + const EUserProjectRoles = { + VIEWER: 1, + MEMBER: 2, + ADMIN: 3, + } as const; + return { + EUserWorkspaceRoles, + EUserProjectRoles, + }; +}); + +// Service mocks +const mockWorkspaceServiceInstance = { + workspaceMemberMe: vi.fn(), + getWorkspaceUserProjectsRole: vi.fn(), +}; +vi.mock("@/plane-web/services/workspace.service", () => { + return { + WorkspaceService: vi.fn(() => mockWorkspaceServiceInstance), + }; +}); + +const mockProjectMemberService = { + projectMemberMe: vi.fn(), +}; +vi.mock("@/services/project/project-member.service", () => ({ + __esModule: true, + default: mockProjectMemberService, +})); + +const mockUserService = { + leaveWorkspace: vi.fn(), + joinProject: vi.fn(), + leaveProject: vi.fn(), +}; +vi.mock("@/services/user.service", () => ({ + __esModule: true, + default: mockUserService, +})); + +// Import after mocks so the module under test binds to mocked services. +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EUserWorkspaceRoles } from "@plane/types"; + +// Resolve the store source file path; adapt if the actual path differs. +import { BaseUserPermissionStore } from "../base-permissions.store"; + +// Build a concrete test subclass to implement the abstract method by delegating to the protected helper. +class TestUserPermissionStore extends BaseUserPermissionStore { + // Delegate to protected getProjectRole to preserve business logic (incl. workspace ADMIN escalation). + getProjectRoleByWorkspaceSlugAndProjectId = (workspaceSlug: string, projectId: string) => { + // @ts-ignore protected access allowed in subclass + return this.getProjectRole(workspaceSlug, projectId); + }; +} + +// Minimal RootStore stub matching fields accessed by the store +type RouterStub = { workspaceSlug?: string; projectId?: string }; +type RootStoreStub = { + router: RouterStub; + projectRoot: { project: { projectMap: Record } }; +}; + +// Helpers to build a fresh store for each test +const makeStore = (router: RouterStub = {}): { store: TestUserPermissionStore; root: RootStoreStub } => { + const root: RootStoreStub = { + router, + projectRoot: { project: { projectMap: {} } }, + }; + // @ts-expect-error RootStore is broader; we use a focused stub for tests. + const store = new TestUserPermissionStore(root); + return { store, root }; +}; + +describe("BaseUserPermissionStore - computed helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("workspaceInfoBySlug returns undefined for falsy slug", () => { + const { store } = makeStore(); + expect(store.workspaceInfoBySlug("")).toBeUndefined(); + }); + + it("workspaceInfoBySlug returns workspace info when present", () => { + const { store } = makeStore(); + // Arrange + // @ts-ignore minimal shape + store.workspaceUserInfo["ws1"] = { role: EUserWorkspaceRoles.MEMBER }; + // Act + Assert + expect(store.workspaceInfoBySlug("ws1")).toEqual({ role: EUserWorkspaceRoles.MEMBER }); + }); + + it("getWorkspaceRoleByWorkspaceSlug returns the role or undefined", () => { + const { store } = makeStore(); + expect(store.getWorkspaceRoleByWorkspaceSlug("unknown")).toBeUndefined(); + // @ts-ignore minimal shape + store.workspaceUserInfo["wsX"] = { role: EUserWorkspaceRoles.ADMIN }; + expect(store.getWorkspaceRoleByWorkspaceSlug("wsX")).toBe(EUserWorkspaceRoles.ADMIN); + }); + + it("getProjectRolesByWorkspaceSlug maps project entries via abstract getProjectRoleByWorkspaceSlugAndProjectId", () => { + const { store } = makeStore(); + // Seed project roles + // @ts-ignore storing raw numeric roles is sufficient + store.workspaceProjectsPermissions["ws1"] = { p1: EUserPermissions.MEMBER, p2: EUserPermissions.VIEWER }; + const result = store.getProjectRolesByWorkspaceSlug("ws1"); + expect(result).toEqual({ p1: EUserPermissions.MEMBER, p2: EUserPermissions.VIEWER }); + }); + + it("getProjectRolesByWorkspaceSlug respects workspace ADMIN escalation", () => { + const { store } = makeStore(); + // Workspace ADMIN + // @ts-ignore minimal shape + store.workspaceUserInfo["ws1"] = { role: EUserWorkspaceRoles.ADMIN }; + // Seed lower project roles + // @ts-ignore + store.workspaceProjectsPermissions["ws1"] = { p1: EUserPermissions.MEMBER, p2: EUserPermissions.VIEWER }; + const result = store.getProjectRolesByWorkspaceSlug("ws1"); + expect(result).toEqual({ p1: EUserPermissions.ADMIN, p2: EUserPermissions.ADMIN }); + }); +}); + +describe("BaseUserPermissionStore - hasPageAccess", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns false for missing inputs or unknown key", () => { + const { store } = makeStore(); + expect(store.hasPageAccess("", "settings")).toBe(false); + expect(store.hasPageAccess("ws1", "unknown-key")).toBe(false); + }); + + it("allows access when workspace role satisfies required permissions", () => { + const { store } = makeStore(); + // @ts-ignore minimal shape + store.workspaceUserInfo["ws1"] = { role: "3" }; // string role that should parse to number 3 (ADMIN) + expect(store.hasPageAccess("ws1", "settings")).toBe(true); // settings requires ADMIN + }); + + it("denies access when role insufficient", () => { + const { store } = makeStore(); + // @ts-ignore minimal shape + store.workspaceUserInfo["ws1"] = { role: EUserPermissions.VIEWER }; // 1 + expect(store.hasPageAccess("ws1", "dashboard")).toBe(false); // needs MEMBER or ADMIN + }); +}); + +describe("BaseUserPermissionStore - allowPermissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns false when no role is resolvable", () => { + const { store } = makeStore({ workspaceSlug: "ws1" }); + // No workspaceUserInfo seeded + expect( + store.allowPermissions([EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE) + ).toBe(false); + }); + + it("workspace-level: grants when role is in allowed set (with onPermissionAllowed === true)", () => { + const { store } = makeStore({ workspaceSlug: "ws1" }); + // String role verifies parseInt branch + // @ts-ignore + store.workspaceUserInfo["ws1"] = { role: String(EUserPermissions.MEMBER) }; + const onAllowed = vi.fn(() => true); + const granted = store.allowPermissions( + [EUserPermissions.VIEWER, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE, + "ws1", + undefined, + onAllowed + ); + expect(granted).toBe(true); + expect(onAllowed).toHaveBeenCalledTimes(1); + }); + + it("workspace-level: honors onPermissionAllowed returning false", () => { + const { store } = makeStore({ workspaceSlug: "ws1" }); + // @ts-ignore + store.workspaceUserInfo["ws1"] = { role: EUserPermissions.ADMIN }; + const onAllowed = vi.fn(() => false); + const granted = store.allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.WORKSPACE, + "ws1", + undefined, + onAllowed + ); + expect(granted).toBe(false); + expect(onAllowed).toHaveBeenCalledTimes(1); + }); + + it("project-level: resolves via getProjectRoleByWorkspaceSlugAndProjectId using router fallbacks", () => { + const { store } = makeStore({ workspaceSlug: "wsA", projectId: "pA" }); + // Seed project role as MEMBER + // @ts-ignore + store.workspaceProjectsPermissions["wsA"] = { pA: EUserPermissions.MEMBER }; + const granted = store.allowPermissions([EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT); + expect(granted).toBe(true); + }); + + it("project-level: denies when role not in allowed set", () => { + const { store } = makeStore({ workspaceSlug: "wsA", projectId: "pA" }); + // @ts-ignore + store.workspaceProjectsPermissions["wsA"] = { pA: EUserPermissions.VIEWER }; + const granted = store.allowPermissions([EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT); + expect(granted).toBe(false); + }); + + it("project-level: workspace ADMIN escalates to ADMIN", () => { + const { store } = makeStore({ workspaceSlug: "wsA", projectId: "pA" }); + // @ts-ignore + store.workspaceUserInfo["wsA"] = { role: EUserWorkspaceRoles.ADMIN }; + // Even if project says VIEWER, workspace ADMIN should escalate to ADMIN + // @ts-ignore + store.workspaceProjectsPermissions["wsA"] = { pA: EUserPermissions.VIEWER }; + const granted = store.allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + expect(granted).toBe(true); + }); +}); + +describe("BaseUserPermissionStore - actions (service-backed)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetchUserWorkspaceInfo sets loader and stores response", async () => { + const { store } = makeStore(); + mockWorkspaceServiceInstance.workspaceMemberMe.mockResolvedValueOnce({ role: EUserWorkspaceRoles.MEMBER }); + const p = store.fetchUserWorkspaceInfo("ws1"); + // loader should be true during the request + expect(store.loader).toBe(true); + const resp = await p; + expect(resp).toEqual({ role: EUserWorkspaceRoles.MEMBER }); + expect(store.loader).toBe(false); + expect(store.workspaceUserInfo["ws1"]).toEqual({ role: EUserWorkspaceRoles.MEMBER }); + expect(mockWorkspaceServiceInstance.workspaceMemberMe).toHaveBeenCalledWith("ws1"); + }); + + it("fetchUserWorkspaceInfo resets loader and rethrows on error", async () => { + const { store } = makeStore(); + const err = new Error("boom"); + mockWorkspaceServiceInstance.workspaceMemberMe.mockRejectedValueOnce(err); + await expect(store.fetchUserWorkspaceInfo("ws1")).rejects.toThrow("boom"); + expect(store.loader).toBe(false); + }); + + it("leaveWorkspace calls service and clears related state", async () => { + const { store } = makeStore(); + // Seed state + // @ts-ignore + store.workspaceUserInfo["ws1"] = { role: EUserWorkspaceRoles.MEMBER }; + // @ts-ignore + store.projectUserInfo["ws1"] = { p1: { role: EUserPermissions.MEMBER } }; + // @ts-ignore + store.workspaceProjectsPermissions["ws1"] = { p1: EUserPermissions.MEMBER }; + mockUserService.leaveWorkspace.mockResolvedValueOnce(undefined); + + await store.leaveWorkspace("ws1"); + + expect(mockUserService.leaveWorkspace).toHaveBeenCalledWith("ws1"); + expect(store.workspaceUserInfo["ws1"]).toBeUndefined(); + expect(store.projectUserInfo["ws1"]).toBeUndefined(); + expect(store.workspaceProjectsPermissions["ws1"]).toBeUndefined(); + }); + + it("fetchUserProjectInfo stores project membership and updates workspaceProjectsPermissions", async () => { + const { store } = makeStore(); + mockProjectMemberService.projectMemberMe.mockResolvedValueOnce({ + role: EUserPermissions.MEMBER, + id: "member-id", + }); + const data = await store.fetchUserProjectInfo("ws1", "p1"); + expect(data).toEqual({ role: EUserPermissions.MEMBER, id: "member-id" }); + // @ts-ignore + expect(store.projectUserInfo["ws1"]["p1"]).toEqual({ role: EUserPermissions.MEMBER, id: "member-id" }); + // @ts-ignore + expect(store.workspaceProjectsPermissions["ws1"]["p1"]).toBe(EUserPermissions.MEMBER); + expect(mockProjectMemberService.projectMemberMe).toHaveBeenCalledWith("ws1", "p1"); + }); + + it("fetchUserProjectPermissions replaces workspace project roles map", async () => { + const { store } = makeStore(); + mockWorkspaceServiceInstance.getWorkspaceUserProjectsRole.mockResolvedValueOnce({ + p1: EUserPermissions.VIEWER, + p2: EUserPermissions.MEMBER, + }); + const roles = await store.fetchUserProjectPermissions("ws1"); + expect(roles).toEqual({ p1: EUserPermissions.VIEWER, p2: EUserPermissions.MEMBER }); + // @ts-ignore + expect(store.workspaceProjectsPermissions["ws1"]).toEqual({ p1: EUserPermissions.VIEWER, p2: EUserPermissions.MEMBER }); + expect(mockWorkspaceServiceInstance.getWorkspaceUserProjectsRole).toHaveBeenCalledWith("ws1"); + }); + + it("joinProject calls user service and sets project role from workspace role or MEMBER fallback", async () => { + // Case A: workspace role present -> used + { + const { store } = makeStore(); + // @ts-ignore + store.workspaceUserInfo["ws1"] = { role: EUserPermissions.ADMIN }; + mockUserService.joinProject.mockResolvedValueOnce(undefined); + await store.joinProject("ws1", "p1"); + // @ts-ignore + expect(store.workspaceProjectsPermissions["ws1"]["p1"]).toBe(EUserPermissions.ADMIN); + expect(mockUserService.joinProject).toHaveBeenCalledWith("ws1", ["p1"]); + } + vi.clearAllMocks(); + // Case B: workspace role missing -> fallback to MEMBER + { + const { store } = makeStore(); + mockUserService.joinProject.mockResolvedValueOnce(undefined); + await store.joinProject("ws1", "pX"); + // @ts-ignore + expect(store.workspaceProjectsPermissions["ws1"]["pX"]).toBe(EUserPermissions.MEMBER); + } + }); + + it("leaveProject calls user service and clears project data + projectMap", async () => { + const { store, root } = makeStore(); + // Seed data + // @ts-ignore + store.workspaceProjectsPermissions["ws1"] = { p1: EUserPermissions.MEMBER, p2: EUserPermissions.ADMIN }; + // @ts-ignore + store.projectUserInfo["ws1"] = { p1: { role: EUserPermissions.MEMBER } }; + root.projectRoot.project.projectMap["p1"] = { name: "Project 1" }; + + mockUserService.leaveProject.mockResolvedValueOnce(undefined); + + await store.leaveProject("ws1", "p1"); + + expect(mockUserService.leaveProject).toHaveBeenCalledWith("ws1", "p1"); + // @ts-ignore + expect(store.workspaceProjectsPermissions["ws1"]["p1"]).toBeUndefined(); + // @ts-ignore + expect(store.projectUserInfo["ws1"]["p1"]).toBeUndefined(); + expect(root.projectRoot.project.projectMap["p1"]).toBeUndefined(); + // Ensure other project untouched + // @ts-ignore + expect(store.workspaceProjectsPermissions["ws1"]["p2"]).toBe(EUserPermissions.ADMIN); + }); +}); + +describe("BaseUserPermissionStore - negative paths for actions", () => { + beforeEach(() => vi.clearAllMocks()); + + it("fetchUserProjectInfo rethrows service error", async () => { + const { store } = makeStore(); + mockProjectMemberService.projectMemberMe.mockRejectedValueOnce(new Error("proj error")); + await expect(store.fetchUserProjectInfo("ws1", "p1")).rejects.toThrow("proj error"); + }); + + it("fetchUserProjectPermissions rethrows service error", async () => { + const { store } = makeStore(); + mockWorkspaceServiceInstance.getWorkspaceUserProjectsRole.mockRejectedValueOnce(new Error("perm error")); + await expect(store.fetchUserProjectPermissions("ws1")).rejects.toThrow("perm error"); + }); + + it("leaveWorkspace rethrows service error", async () => { + const { store } = makeStore(); + mockUserService.leaveWorkspace.mockRejectedValueOnce(new Error("leave w error")); + await expect(store.leaveWorkspace("ws1")).rejects.toThrow("leave w error"); + }); + + it("joinProject rethrows service error", async () => { + const { store } = makeStore(); + mockUserService.joinProject.mockRejectedValueOnce(new Error("join error")); + await expect(store.joinProject("ws1", "p1")).rejects.toThrow("join error"); + }); + + it("leaveProject rethrows service error", async () => { + const { store } = makeStore(); + mockUserService.leaveProject.mockRejectedValueOnce(new Error("leave p error")); + await expect(store.leaveProject("ws1", "p1")).rejects.toThrow("leave p error"); + }); +}); \ No newline at end of file