Skip to content

Commit 4a0746b

Browse files
fix: scope workspace user preference filter to current user (#9279)
* fix: scope workspace user preference filter to current user Without user=request.user on the PATCH filter, the ORM could match another user's preference record in the same workspace, causing pin/unpin state to leak across users or silently fail to persist. Fixes #9260 Signed-off-by: okxint <cashmein.eth@gmail.com> * test: add regression coverage for workspace user preference scoping (#9260) Adds contract tests for the sidebar preference PATCH endpoint: - test_patch_only_updates_requesting_users_preference: in a multi-member workspace, a member's PATCH must update only their own preference row, never another member's. Fails against the pre-fix code (the unscoped .first() mutates the most-recently-created row regardless of user). - test_patch_updates_own_preference: baseline that a member's PATCH persists to their own row. Verified RED on the unpatched view and GREEN with the user=request.user filter from #9261. * fix(api): wrap long line to satisfy ruff E501 in user preference view --------- Signed-off-by: okxint <cashmein.eth@gmail.com> Co-authored-by: okxint <cashmein.eth@gmail.com>
1 parent 64da8dc commit 4a0746b

2 files changed

Lines changed: 86 additions & 1 deletion

File tree

apps/api/plane/app/views/workspace/user_preference.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ def patch(self, request, slug):
8585
if not key:
8686
continue
8787

88-
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
88+
preference = WorkspaceUserPreference.objects.filter(
89+
key=key, workspace__slug=slug, user=request.user
90+
).first()
8991

9092
if not preference:
9193
continue
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright (c) 2023-present Plane Software, Inc. and contributors
2+
# SPDX-License-Identifier: AGPL-3.0-only
3+
# See the LICENSE file for details.
4+
5+
from datetime import timedelta
6+
7+
import pytest
8+
from django.urls import reverse
9+
from django.utils import timezone
10+
from rest_framework import status
11+
12+
from plane.db.models import User, WorkspaceMember
13+
from plane.db.models.workspace import WorkspaceUserPreference
14+
15+
16+
@pytest.mark.contract
17+
class TestWorkspaceUserPreferencePatch:
18+
"""Contract tests for the sidebar preference PATCH endpoint.
19+
20+
Regression coverage for #9260: ``patch`` filtered ``WorkspaceUserPreference``
21+
by ``key``/``workspace__slug`` only, so in a workspace with multiple members
22+
``.first()`` (ordered by ``-created_at``) could return — and mutate — another
23+
member's preference row instead of the requesting user's.
24+
"""
25+
26+
KEY = WorkspaceUserPreference.UserPreferenceKeys.ANALYTICS.value
27+
28+
@pytest.mark.django_db
29+
def test_patch_only_updates_requesting_users_preference(self, session_client, create_user, workspace):
30+
"""A member's PATCH must update only their own preference, never another member's."""
31+
# A second, more-recently-active member of the same workspace.
32+
other_user = User.objects.create(
33+
email="other@plane.so", username="other_user", first_name="Other", last_name="User"
34+
)
35+
WorkspaceMember.objects.create(workspace=workspace, member=other_user, role=15)
36+
37+
own_pref = WorkspaceUserPreference.objects.create(
38+
workspace=workspace, user=create_user, key=self.KEY, is_pinned=False, sort_order=100
39+
)
40+
other_pref = WorkspaceUserPreference.objects.create(
41+
workspace=workspace, user=other_user, key=self.KEY, is_pinned=False, sort_order=200
42+
)
43+
44+
# Force the other member's row to sort first under the model's ``-created_at``
45+
# ordering, so an unscoped ``.first()`` would deterministically pick it.
46+
now = timezone.now()
47+
WorkspaceUserPreference.objects.filter(pk=own_pref.pk).update(created_at=now)
48+
WorkspaceUserPreference.objects.filter(pk=other_pref.pk).update(created_at=now + timedelta(minutes=1))
49+
50+
url = reverse("workspace-user-preference", kwargs={"slug": workspace.slug})
51+
response = session_client.patch(
52+
url, [{"key": self.KEY, "is_pinned": True, "sort_order": 999}], format="json"
53+
)
54+
55+
assert response.status_code == status.HTTP_200_OK
56+
57+
own_pref.refresh_from_db()
58+
other_pref.refresh_from_db()
59+
60+
# The requesting user's preference is updated...
61+
assert own_pref.is_pinned is True
62+
assert own_pref.sort_order == 999
63+
# ...and the other member's preference is left untouched.
64+
assert other_pref.is_pinned is False
65+
assert other_pref.sort_order == 200
66+
67+
@pytest.mark.django_db
68+
def test_patch_updates_own_preference(self, session_client, create_user, workspace):
69+
"""Baseline: a member's PATCH persists changes to their own preference row."""
70+
preference = WorkspaceUserPreference.objects.create(
71+
workspace=workspace, user=create_user, key=self.KEY, is_pinned=False, sort_order=100
72+
)
73+
74+
url = reverse("workspace-user-preference", kwargs={"slug": workspace.slug})
75+
response = session_client.patch(
76+
url, [{"key": self.KEY, "is_pinned": True, "sort_order": 42}], format="json"
77+
)
78+
79+
assert response.status_code == status.HTTP_200_OK
80+
81+
preference.refresh_from_db()
82+
assert preference.is_pinned is True
83+
assert preference.sort_order == 42

0 commit comments

Comments
 (0)