diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py index cff5ad086f..082a7d435b 100644 --- a/apps/api/plane/space/views/intake.py +++ b/apps/api/plane/space/views/intake.py @@ -113,6 +113,14 @@ class IntakeIssuePublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Ensure the intake belongs to this board before writing: a + # caller-supplied intake_id must be bound to the anchor. + if str(intake_id) != str(project_deploy_board.intake_id): + return Response( + {"error": "Intake does not belong to this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not request.data.get("issue", {}).get("name", False): return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) @@ -142,7 +150,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): default=False, ) - # Sanitize description_html before saving to prevent stored XSS (GHSA-hh2r-3hwp-mvq3) + # Sanitize description_html before saving to prevent stored XSS raw_description_html = request.data.get("issue", {}).get("description_html", "
") _, _, sanitized_description_html = validate_html_content(raw_description_html) safe_description_html = sanitized_description_html if sanitized_description_html is not None else "" diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 386d4c84d2..b5f0ad6f73 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -70,6 +70,33 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_filters import issue_filters +# Public Spaces board writes must bind caller-supplied object ids to the board's +# project + workspace. Shared by every create() so the check can't drift between +# endpoints or be forgotten on a new one. +def _issue_in_board_scope(issue_id, project_deploy_board): + """A board-visible issue in the board's project + workspace. + + Uses ``issue_objects`` (excludes draft/archived/triage) to match exactly + what the public board displays via ProjectIssuesPublicEndpoint / + IssueRetrievePublicEndpoint — you can only write on what the board shows. + """ + return Issue.issue_objects.filter( + id=issue_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + ).exists() + + +def _comment_in_board_scope(comment_id, project_deploy_board): + """A public (EXTERNAL) comment in the board's project + workspace.""" + return IssueComment.objects.filter( + id=comment_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + access="EXTERNAL", + ).exists() + + class ProjectIssuesPublicEndpoint(BaseAPIView): permission_classes = [AllowAny] @@ -233,6 +260,7 @@ class IssueCommentPublicViewSet(BaseViewSet): super() .get_queryset() .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) .filter(issue_id=self.kwargs.get("issue_id")) .filter(access="EXTERNAL") .select_related("project") @@ -263,6 +291,10 @@ class IssueCommentPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Bind the caller-supplied issue_id to this board. + if not _issue_in_board_scope(issue_id, project_deploy_board): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -301,7 +333,15 @@ class IssueCommentPublicViewSet(BaseViewSet): {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, ) - comment = IssueComment.objects.get(pk=pk, actor=request.user) + # Bind the comment to this board + issue, matching create()/get_queryset(). + comment = IssueComment.objects.get( + pk=pk, + issue_id=issue_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + access="EXTERNAL", + actor=request.user, + ) serializer = IssueCommentSerializer(comment, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -325,7 +365,15 @@ class IssueCommentPublicViewSet(BaseViewSet): {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, ) - comment = IssueComment.objects.get(pk=pk, actor=request.user) + # Bind the comment to this board + issue, matching create()/get_queryset(). + comment = IssueComment.objects.get( + pk=pk, + issue_id=issue_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + access="EXTERNAL", + actor=request.user, + ) issue_activity.delay( type="comment.activity.deleted", requested_data=json.dumps({"comment_id": str(pk)}), @@ -372,6 +420,10 @@ class IssueReactionPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Bind the caller-supplied issue_id to this board. + if not _issue_in_board_scope(issue_id, project_deploy_board): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = IssueReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -408,8 +460,12 @@ class IssueReactionPublicViewSet(BaseViewSet): {"error": "Reactions are not enabled for this project board"}, status=status.HTTP_400_BAD_REQUEST, ) + # Bind the reaction to this board's project, not just its workspace, + # matching create() - otherwise a reaction on an issue in a different + # project of the same workspace could be reached through this board. issue_reaction = IssueReaction.objects.get( workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, issue_id=issue_id, reaction=reaction_code, actor=request.user, @@ -441,6 +497,10 @@ class CommentReactionPublicViewSet(BaseViewSet): .filter(workspace_id=project_deploy_board.workspace_id) .filter(project_id=project_deploy_board.project_id) .filter(comment_id=self.kwargs.get("comment_id")) + # Only reactions on public (EXTERNAL) comments are visible + # through the public board, matching create()'s + # _comment_in_board_scope check. + .filter(comment__access="EXTERNAL") .order_by("-created_at") .distinct() ) @@ -457,6 +517,10 @@ class CommentReactionPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Bind the caller-supplied comment_id to this board. + if not _comment_in_board_scope(comment_id, project_deploy_board): + return Response({"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -478,7 +542,13 @@ class CommentReactionPublicViewSet(BaseViewSet): requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(self.request.user.id), issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), + # This route's URL only supplies `anchor` and `comment_id`, + # never `project_id` - self.kwargs.get("project_id") was + # always None here, silently corrupting the activity log for + # every comment reaction created on a public board. Use the + # project resolved from the deploy board, matching destroy() + # below. + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) @@ -493,10 +563,13 @@ class CommentReactionPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Only a reaction on a public (EXTERNAL) comment bound to this board + # can be removed through it, matching create() and get_queryset(). comment_reaction = CommentReaction.objects.get( project_id=project_deploy_board.project_id, workspace_id=project_deploy_board.workspace_id, comment_id=comment_id, + comment__access="EXTERNAL", reaction=reaction_code, actor=request.user, ) @@ -542,6 +615,17 @@ class IssueVotePublicViewSet(BaseViewSet): def create(self, request, anchor, issue_id): project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_votes_enabled: + return Response( + {"error": "Votes are not enabled for this project board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Bind the caller-supplied issue_id to this board. + if not _issue_in_board_scope(issue_id, project_deploy_board): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + issue_vote, _ = IssueVote.objects.get_or_create( actor_id=request.user.id, project_id=project_deploy_board.project_id, diff --git a/apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py b/apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py new file mode 100644 index 0000000000..b7a0e66c9d --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py @@ -0,0 +1,575 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for public Spaces board object-ID scoping. + +Regression coverage for WEB-8283. + +The public Spaces board write endpoints (``/api/public/anchor/board external comment
", + access="EXTERNAL", + created_by=create_user, + actor=create_user, + ) + intake = Intake.objects.create(name="Board Intake", project=project, workspace=workspace) + deploy_board = DeployBoard.objects.create( + entity_name="project", + entity_identifier=project.id, + project=project, + workspace=workspace, + is_comments_enabled=True, + is_reactions_enabled=True, + is_votes_enabled=True, + intake=intake, + ) + return { + "project": project, + "issue": issue, + "comment": comment, + "intake": intake, + "anchor": deploy_board.anchor, + } + + +@pytest.fixture +def victim(db, workspace, create_user): + """A *different* project in the SAME workspace, not published on ``board``.""" + project = Project.objects.create( + name="Victim Project", identifier="VIC", workspace=workspace, created_by=create_user + ) + state = State.objects.create( + name="Todo", project=project, workspace=workspace, group="backlog", default=True + ) + issue = Issue.objects.create( + name="Victim Issue", workspace=workspace, project=project, state=state, created_by=create_user + ) + comment = IssueComment.objects.create( + issue=issue, + project=project, + workspace=workspace, + comment_html="secret external comment
", + access="EXTERNAL", + created_by=create_user, + actor=create_user, + ) + intake = Intake.objects.create(name="Victim Intake", project=project, workspace=workspace) + return {"project": project, "issue": issue, "comment": comment, "intake": intake} + + +@pytest.fixture +def victim_other_ws(db, create_user): + """A project in a DIFFERENT workspace — exercises the workspace_id binding + (true cross-tenant, matching the advisory's stated impact).""" + uid = uuid4().hex[:8] + owner = User.objects.create(email=f"victim-owner-{uid}@plane.so", username=f"victim_owner_{uid}") + owner.set_password("test-password") + owner.save() + other_ws = Workspace.objects.create(name="Other WS", owner=owner, slug=f"other-ws-{uid}") + WorkspaceMember.objects.create(workspace=other_ws, member=owner, role=20) + project = Project.objects.create( + name="Other WS Project", identifier="OWP", workspace=other_ws, created_by=owner + ) + state = State.objects.create( + name="Todo", project=project, workspace=other_ws, group="backlog", default=True + ) + issue = Issue.objects.create( + name="Other WS Issue", workspace=other_ws, project=project, state=state, created_by=owner + ) + return {"workspace": other_ws, "project": project, "issue": issue} + + +@pytest.fixture +def board_votes_disabled(db, workspace, create_user): + """A published board with voting DISABLED, for the is_votes_enabled gate.""" + project = Project.objects.create( + name="No-Vote Project", identifier="NVP", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create( + project=project, member=create_user, workspace=workspace, role=20, is_active=True + ) + state = State.objects.create( + name="Todo", project=project, workspace=workspace, group="backlog", default=True + ) + issue = Issue.objects.create( + name="No-Vote Issue", workspace=workspace, project=project, state=state, created_by=create_user + ) + deploy_board = DeployBoard.objects.create( + entity_name="project", + entity_identifier=project.id, + project=project, + workspace=workspace, + is_comments_enabled=True, + is_reactions_enabled=True, + is_votes_enabled=False, + intake=None, + ) + return {"project": project, "issue": issue, "anchor": deploy_board.anchor} + + +@pytest.fixture +def attacker_client(db, workspace): + """An authenticated user who is NOT a member of the victim project.""" + uid = uuid4().hex[:8] + user = User.objects.create(email=f"attacker-{uid}@plane.so", username=f"attacker_{uid}") + user.set_password("test-password") + user.save() + WorkspaceMember.objects.create(workspace=workspace, member=user, role=15) + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def internal_comment(db, workspace, board, create_user): + """An INTERNAL (non-public) comment on the board's own issue, with its own + reaction - never shown on the public board, so it must not be readable or + mutable through the public reaction endpoints either.""" + comment = IssueComment.objects.create( + issue=board["issue"], + project=board["project"], + workspace=workspace, + comment_html="internal only
", + access="INTERNAL", + created_by=create_user, + actor=create_user, + ) + reaction = CommentReaction.objects.create( + project=board["project"], + workspace=workspace, + comment=comment, + actor=create_user, + reaction="fire", + ) + return {"comment": comment, "reaction": reaction} + + +@pytest.fixture +def board_issue_reaction(db, workspace, board, create_user): + """A reaction already on the board's own issue, for destroy() coverage.""" + return IssueReaction.objects.create( + project=board["project"], + workspace=workspace, + issue=board["issue"], + actor=create_user, + reaction="smile", + ) + + +@pytest.fixture +def victim_issue_reaction(db, victim, create_user): + """A reaction the same user owns, but on an issue in a DIFFERENT project + (not published by ``board``) - exercises the project binding on destroy().""" + return IssueReaction.objects.create( + project=victim["project"], + workspace=victim["project"].workspace, + issue=victim["issue"], + actor=create_user, + reaction="smile", + ) + + +# --------------------------------------------------------------------------- # +# Cross-tenant WRITE — must be rejected (404 for issue/comment binding, +# 400 for the intake binding mismatch, per each endpoint's contract below) +# --------------------------------------------------------------------------- # +@pytest.mark.contract +class TestSpacesBoardObjectScope: + @pytest.mark.django_db + def test_cannot_comment_on_issue_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + comments_url(board["anchor"], victim["issue"].id), + {"comment_html": "injected
"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueComment.objects.filter( + issue_id=victim["issue"].id, comment_html="injected
" + ).exists() + + @pytest.mark.django_db + def test_cannot_react_to_issue_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + issue_reactions_url(board["anchor"], victim["issue"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_vote_on_issue_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + votes_url(board["anchor"], victim["issue"].id), + {"vote": 1}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_react_to_comment_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + comment_reactions_url(board["anchor"], victim["comment"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_create_intake_issue_with_foreign_intake(self, attacker_client, board, victim): + response = attacker_client.post( + intake_issues_url(board["anchor"], victim["intake"].id), + {"issue": {"name": "injected intake"}}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_comment_on_issue_in_other_workspace(self, attacker_client, board, victim_other_ws): + """The guard binds on workspace_id too — a board's anchor cannot reach an + issue in a different workspace (true cross-tenant vector).""" + response = attacker_client.post( + comments_url(board["anchor"], victim_other_ws["issue"].id), + {"comment_html": "injected cross-ws
"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueComment.objects.filter( + issue_id=victim_other_ws["issue"].id, comment_html="injected cross-ws
" + ).exists() + + # ----------------------------------------------------------------------- # + # Feature gate — vote create must honor is_votes_enabled (parity fix) + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_cannot_vote_when_votes_disabled(self, session_client, board_votes_disabled): + response = session_client.post( + votes_url(board_votes_disabled["anchor"], board_votes_disabled["issue"].id), + {"vote": 1}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + # ----------------------------------------------------------------------- # + # Cross-project READ leak — comment list must not surface other projects + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_comment_list_does_not_leak_cross_project(self, attacker_client, board, victim): + response = attacker_client.get(comments_url(board["anchor"], victim["issue"].id)) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + results = response.data["results"] if isinstance(response.data, dict) else response.data + returned_ids = {str(c["id"]) for c in results} + assert str(victim["comment"].id) not in returned_ids, ( + "Victim project's EXTERNAL comment leaked through another board's anchor" + ) + + @pytest.mark.django_db + def test_comment_list_returns_own_project_comments(self, session_client, board): + """Positive control: the board's own EXTERNAL comments are still listed + (guards against an over-broad project_id filter returning nothing).""" + response = session_client.get(comments_url(board["anchor"], board["issue"].id)) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + results = response.data["results"] if isinstance(response.data, dict) else response.data + returned_ids = {str(c["id"]) for c in results} + assert str(board["comment"].id) in returned_ids, ( + "Board's own EXTERNAL comment was wrongly filtered out" + ) + + # ----------------------------------------------------------------------- # + # Positive controls — legitimate writes on the board's own issue still work + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_can_comment_on_issue_in_board_project(self, session_client, board): + response = session_client.post( + comments_url(board["anchor"], board["issue"].id), + {"comment_html": "legit
"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_vote_on_issue_in_board_project(self, session_client, board): + response = session_client.post( + votes_url(board["anchor"], board["issue"].id), + {"vote": 1}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_react_to_issue_in_board_project(self, session_client, board): + response = session_client.post( + issue_reactions_url(board["anchor"], board["issue"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_react_to_comment_in_board_project(self, session_client, board): + response = session_client.post( + comment_reactions_url(board["anchor"], board["comment"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_create_intake_issue_with_own_intake(self, session_client, board): + response = session_client.post( + intake_issues_url(board["anchor"], board["intake"].id), + {"issue": {"name": "legit intake issue"}}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + +# --------------------------------------------------------------------------- # +# Read/update/delete scope gaps — the board-scope guards were only applied to +# create(). A caller could still read reactions on an INTERNAL (non-public) +# comment, or reach a comment/reaction they authored through a board it does +# not belong to, via list/update/delete. These mirror the create() coverage +# above for the list/partial_update/destroy paths. +# --------------------------------------------------------------------------- # +@pytest.mark.contract +class TestSpacesBoardObjectScopeMutations: + # ----------------------------------------------------------------------- # + # CommentReactionPublicViewSet — INTERNAL comments must stay invisible + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_comment_reaction_list_does_not_leak_internal_comment(self, session_client, board, internal_comment): + response = session_client.get(comment_reactions_url(board["anchor"], internal_comment["comment"].id)) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + results = response.data["results"] if isinstance(response.data, dict) else response.data + returned_ids = {str(r["id"]) for r in results} + assert str(internal_comment["reaction"].id) not in returned_ids, ( + "Reaction on an INTERNAL comment leaked through the public reaction list" + ) + + @pytest.mark.django_db + def test_cannot_delete_reaction_on_internal_comment(self, session_client, board, internal_comment): + response = session_client.delete( + comment_reaction_detail_url(board["anchor"], internal_comment["comment"].id, "fire") + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert CommentReaction.objects.filter(id=internal_comment["reaction"].id).exists(), ( + "Reaction on an INTERNAL comment was deleted through the public board" + ) + + @pytest.mark.django_db + def test_can_delete_own_reaction_on_external_comment(self, session_client, board, create_user): + reaction = CommentReaction.objects.create( + project=board["project"], + workspace=board["project"].workspace, + comment=board["comment"], + actor=create_user, + reaction="tada", + ) + response = session_client.delete(comment_reaction_detail_url(board["anchor"], board["comment"].id, "tada")) + assert response.status_code == status.HTTP_204_NO_CONTENT, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not CommentReaction.objects.filter(id=reaction.id).exists() + + # ----------------------------------------------------------------------- # + # IssueCommentPublicViewSet.partial_update / destroy — must bind pk to + # this board's project + workspace + issue_id, not just the actor + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_cannot_update_comment_reached_through_wrong_board(self, session_client, board, victim): + response = session_client.patch( + comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id), + {"comment_html": "hijacked
"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + victim["comment"].refresh_from_db() + assert victim["comment"].comment_html != "hijacked
" + + @pytest.mark.django_db + def test_cannot_delete_comment_reached_through_wrong_board(self, session_client, board, victim): + response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id)) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert IssueComment.objects.filter(id=victim["comment"].id).exists() + + @pytest.mark.django_db + def test_can_update_own_comment_in_board_project(self, session_client, board): + response = session_client.patch( + comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id), + {"comment_html": "edited
"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_delete_own_comment_in_board_project(self, session_client, board): + response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id)) + assert response.status_code == status.HTTP_204_NO_CONTENT, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueComment.objects.filter(id=board["comment"].id).exists() + + # ----------------------------------------------------------------------- # + # IssueReactionPublicViewSet.destroy — must bind to project, not just + # workspace + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_cannot_delete_reaction_on_issue_outside_board_project( + self, session_client, board, victim, victim_issue_reaction + ): + response = session_client.delete(issue_reaction_detail_url(board["anchor"], victim["issue"].id, "smile")) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert IssueReaction.objects.filter(id=victim_issue_reaction.id).exists(), ( + "Reaction on an issue outside the board's project was deleted through this board" + ) + + @pytest.mark.django_db + def test_can_delete_own_reaction_on_issue_in_board_project(self, session_client, board, board_issue_reaction): + response = session_client.delete(issue_reaction_detail_url(board["anchor"], board["issue"].id, "smile")) + assert response.status_code == status.HTTP_204_NO_CONTENT, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueReaction.objects.filter(id=board_issue_reaction.id).exists()