[WEB-8283] fix: bind Spaces board object IDs to the anchor's project (#9442)

* [WEB-8283] fix: bind Spaces board object IDs to the anchor's project

The public Spaces board endpoints resolved the DeployBoard from the URL
anchor but trusted the caller-supplied issue_id/comment_id/intake_id
verbatim, without verifying the object belonged to that board's
project/workspace. Any authenticated user could write comments,
reactions and votes onto arbitrary issues cross-tenant, and read EXTERNAL
comments from a different project in the same workspace.

Bind every caller-supplied object id to the board's project + workspace
before writing:
- comment / issue-reaction / vote create: require the issue to exist in
  the board's project via Issue.issue_objects (excludes draft/archived/
  triage), else 404.
- comment-reaction create: require the comment to exist in the board's
  project with access="EXTERNAL", else 404.
- intake create: require the URL intake_id to match the board's intake,
  else 400.
- comment list read: scope the queryset to the board's project_id.

Also add the missing is_votes_enabled gate on vote create for parity with
comment/reaction create (pre-existing gap in the same method).

Adds contract regression tests (fail-before verified): cross-tenant
writes and the cross-project comment read now rejected, with positive
controls confirming legitimate board writes/reads still succeed.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8283] test: address Copilot review — cross-workspace + votes-disabled coverage

- Add cross-workspace write test (issue in a different workspace) to
  exercise the workspace_id binding, matching the advisory's cross-tenant
  impact (previously only same-workspace/different-project was covered).
- Add a regression test for the new is_votes_enabled gate on vote create
  (votes-disabled board → 400), preventing the pre-existing gap from
  reappearing.
- Clarify the section header comment: cross-tenant writes return 404 for
  issue/comment binding, 400 for the intake binding mismatch.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8283] refactor: extract board-scope guards into shared helpers

Address CodeRabbit review: the identical "object belongs to the board's
project+workspace" existence check was duplicated across four create()
methods (in four different ViewSets). Extract two module-level helpers —
_issue_in_board_scope and _comment_in_board_scope — so the check is a
single source of truth and cannot drift between endpoints or be forgotten
on a new one (the exact class of bug this PR fixes).

Behavior-preserving; 14 contract tests still green.

Co-authored-by: Plane AI <noreply@plane.so>

* chore(security): drop advisory identifiers from code comments

Explanations kept unchanged; only the IDs are removed.

Co-authored-by: Plane AI <noreply@plane.so>

* fix(security): use the deploy board's project_id in the reaction-create activity log

CommentReactionPublicViewSet.create() logged the activity with
str(self.kwargs.get("project_id", None)) — this route's URL only ever
supplies anchor and comment_id, never project_id, so every comment
reaction created on a public board logged project_id="None", silently
corrupting the activity/audit trail. destroy() on the same viewset
already resolves the correct project_id from the deploy board; create()
now does the same.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8283] fix: apply board-scope guards to comment/reaction read, update and delete paths

The board/issue/external-comment scoping added by this PR's create() methods
was never applied to the list, update and delete paths built on the same
models. A caller could read reactions on an INTERNAL (non-public) comment
through the public reaction list, or reach a comment or reaction they
authored through a board it doesn't actually belong to via partial_update()
or destroy(), since those methods looked up objects by pk/actor only.

Bind IssueCommentPublicViewSet.partial_update()/destroy() to the board's
project, workspace, issue_id and EXTERNAL access; bind
IssueReactionPublicViewSet.destroy() to the board's project (previously only
workspace-scoped); and bind CommentReactionPublicViewSet.get_queryset()/
destroy() to EXTERNAL comments only. Add regression coverage for each gap,
plus positive controls confirming legitimate reads/writes on the board's own
objects still work.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
This commit is contained in:
Manish Gupta
2026-08-28 00:17:41 +05:30
committed by GitHub
parent d25b99cae2
commit 5b5af0aeca
3 changed files with 671 additions and 4 deletions

View File

@@ -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", "<p></p>")
_, _, sanitized_description_html = validate_html_content(raw_description_html)
safe_description_html = sanitized_description_html if sanitized_description_html is not None else "<p></p>"

View File

@@ -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,

View File

@@ -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/<anchor>/...``)
resolve the ``DeployBoard`` from the URL ``anchor`` but previously trusted the
caller-supplied ``issue_id`` / ``comment_id`` / ``intake_id`` verbatim, without
verifying the target object belonged to that board's project/workspace. Any
authenticated user could therefore write a comment / reaction / vote onto an
arbitrary issue in any project (cross-tenant), and read EXTERNAL comments from a
different project in the same workspace.
The fix binds every caller-supplied object id to the board's project + workspace
before writing, and scopes the comment-list read to the board's project.
"""
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
CommentReaction,
DeployBoard,
Intake,
Issue,
IssueComment,
IssueReaction,
Project,
ProjectMember,
State,
User,
Workspace,
WorkspaceMember,
)
# --------------------------------------------------------------------------- #
# URL helpers
# --------------------------------------------------------------------------- #
def comments_url(anchor, issue_id):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/"
def issue_reactions_url(anchor, issue_id):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/"
def comment_reactions_url(anchor, comment_id):
return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/"
def votes_url(anchor, issue_id):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/votes/"
def intake_issues_url(anchor, intake_id):
return f"/api/public/anchor/{anchor}/intakes/{intake_id}/intake-issues/"
def comment_detail_url(anchor, issue_id, pk):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/{pk}/"
def issue_reaction_detail_url(anchor, issue_id, reaction_code):
return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/{reaction_code}/"
def comment_reaction_detail_url(anchor, comment_id, reaction_code):
return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/{reaction_code}/"
# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def _no_activity(db):
"""Stub the deferred activity task so writes never touch the broker."""
with (
mock.patch("plane.space.views.issue.issue_activity"),
mock.patch("plane.space.views.intake.issue_activity"),
):
yield
@pytest.fixture
def board(db, workspace, create_user):
"""A published project board (comments/reactions/votes + intake enabled).
``create_user`` (session_client) is an active member of this project.
"""
project = Project.objects.create(
name="Board Project", identifier="BRD", 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="Board Issue", workspace=workspace, project=project, state=state, created_by=create_user
)
comment = IssueComment.objects.create(
issue=issue,
project=project,
workspace=workspace,
comment_html="<p>board external comment</p>",
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="<p>secret external comment</p>",
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="<p>internal only</p>",
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": "<p>injected</p>"},
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="<p>injected</p>"
).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": "<p>injected cross-ws</p>"},
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="<p>injected cross-ws</p>"
).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": "<p>legit</p>"},
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": "<p>hijacked</p>"},
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 != "<p>hijacked</p>"
@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": "<p>edited</p>"},
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()