Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a70584b
UN-2868 [FIX] Restrict workflow connector and tool changes to owners …
kirtimanmishrazipstack Sep 2, 2026
ddd04af
UN-2868 [FIX] Show the Prompt Studio project name to shared users ins…
kirtimanmishrazipstack Sep 2, 2026
06198cc
UN-2868 [FIX] Do not close the connector modal on a failed or partial…
kirtimanmishrazipstack Sep 2, 2026
e8b07aa
UN-2868 [FIX] Refuse form submits from view-only users, not just clicks
kirtimanmishrazipstack Sep 2, 2026
639baf7
UN-2868 [FIX] Hide edit and delete actions on resources shared with t…
kirtimanmishrazipstack Sep 2, 2026
6feec9d
UN-2868 [FIX] Expose is_owner on the Prompt Studio editor payload
kirtimanmishrazipstack Sep 2, 2026
ed1fe98
UN-2868 [FIX] Keep Share available to shared users; gate only edit an…
kirtimanmishrazipstack Sep 2, 2026
8d405fc
UN-2868 [FIX] Make Prompt Studio settings read-only for shared users
kirtimanmishrazipstack Sep 2, 2026
91c8404
UN-2868 [FIX] Restrict the gate to edit and delete only
kirtimanmishrazipstack Sep 2, 2026
b812041
UN-2868 [FIX] Show Edit and Delete disabled rather than hiding them
kirtimanmishrazipstack Sep 2, 2026
8a84d68
UN-2868 [FIX] Report one outcome when the connector save also writes …
kirtimanmishrazipstack Sep 3, 2026
8a22d05
Merge branch 'main' into UN-2868-sharing-improvements
kirtimanmishrazipstack Sep 3, 2026
f078398
Merge branch 'main' of github.com:Zipstack/unstract into UN-2868-shar…
kirtimanmishrazipstack Sep 7, 2026
3e9156b
UN-2868 [FIX] Prefill the workflow rename form and limit shared-user …
kirtimanmishrazipstack Sep 8, 2026
08d7e4e
Merge remote-tracking branch 'origin/UN-2868-sharing-improvements' in…
kirtimanmishrazipstack Sep 8, 2026
bc0d6e3
UN-2868 [FIX] Make Prompt Studio prompts and settings read-only for s…
kirtimanmishrazipstack Sep 9, 2026
14df8ab
UN-2868 [FIX] Share Prompt Studio for collaboration, and gate the API…
kirtimanmishrazipstack Sep 9, 2026
77c055f
Merge branch 'main' into UN-2868-sharing-improvements
kirtimanmishrazipstack Sep 9, 2026
d6dbc0e
Merge branch 'main' into UN-2868-sharing-improvements
kirtimanmishrazipstack Sep 10, 2026
91170ec
UN-2868 [FIX] Restrict file history deletion to the workflow owner
kirtimanmishrazipstack Sep 10, 2026
44a06b9
UN-2868 [FIX] Scope the endpoint list, correct the rename status, unl…
kirtimanmishrazipstack Sep 10, 2026
0c9fb44
UN-2868 [FIX] Correct three docstrings in prompt_studio/permission.py
kirtimanmishrazipstack Sep 10, 2026
d592324
UN-2868 [FIX] Admit org-shared users to Prompt Studio; make disabled-…
kirtimanmishrazipstack Sep 10, 2026
28cfde6
UN-2868 [FIX] Close the connector-credential path the endpoint-list f…
kirtimanmishrazipstack Sep 10, 2026
ac31510
UN-2868 [FIX] Close two more reparenting sites; simplify the endpoint…
kirtimanmishrazipstack Sep 10, 2026
81f2cea
UN-2868 [FIX] Make the disabled-control tooltip work beyond Button, a…
kirtimanmishrazipstack Sep 10, 2026
c67c55c
UN-2868 [FIX] Guard the workflow_id alias that bypassed the tool repa…
kirtimanmishrazipstack Sep 10, 2026
c349a63
UN-2868 [FIX] Drop the focusable tooltip wrapper SonarCloud flagged
kirtimanmishrazipstack Sep 11, 2026
cb266d4
UN-2868 [FIX] Close the ungated tool-instance create, and cover the g…
kirtimanmishrazipstack Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ class Meta(BaseAdapterSerializer.Meta):
"created_at",
"modified_at",
"description",
"is_friction_less",
) # type: ignore

def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
Expand Down
19 changes: 19 additions & 0 deletions backend/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class APIDeploymentSerializer(IntegrityErrorMixin, AuditSerializer):
# explicitly so ``fields = "__all__"`` continues to expose it. Share
# mutations go through ``POST /api/<id>/share/`` (UN-2977 plan §B).
shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
# Also on the list serializer; a detail response without it reads as
# editable to the UI.
is_owner = serializers.SerializerMethodField()

class Meta:
model = APIDeployment
Expand All @@ -56,6 +59,10 @@ class Meta:
"shared_to_org": {"read_only": True},
}

def get_is_owner(self, obj) -> bool:
request = self.context.get("request")
return obj.is_owner(request.user) if request else False

unique_error_message_map: dict[str, dict[str, str]] = {
"unique_api_name": {
"field": "api_name",
Expand Down Expand Up @@ -171,6 +178,18 @@ def validate(self, data):


class APIKeySerializer(AuditSerializer):
def validate_api(self, value):
"""Refuse reparenting: the gate authorises against the stored parent."""
if self.instance and value != self.instance.api:
raise ValidationError("A key cannot be moved to another deployment.")
return value

def validate_pipeline(self, value):
"""Refuse reparenting: the gate authorises against the stored parent."""
if self.instance and value != self.instance.pipeline:
raise ValidationError("A key cannot be moved to another pipeline.")
return value

class Meta:
model = APIKey
fields = "__all__"
Expand Down
43 changes: 27 additions & 16 deletions backend/permissions/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from adapter_processor_v2.models import AdapterInstance
from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from rest_framework.views import APIView
from tenant_account_v2.organization_member_service import OrganizationMemberService
Expand Down Expand Up @@ -148,24 +149,34 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo
return is_workflow_mutator(request, obj.workflow)


class IsParentToolOwner(permissions.BasePermission):
"""Mutation gate for Prompt Studio sub-resources owned via the parent tool.
class WorkflowOwnerMutationMixin:
"""Viewset mixin gating mutation of a workflow sub-resource.

A ``ProfileManager`` is not a membership resource, so its access is
inherited from the parent ``CustomTool``. Admits the tool's owner (creator +
co-owners), org admin, or service account -- mirrors ``IsParentWorkflowOwner``
(UN-2202). Falls back to the object's own owner when it has no parent tool
(``prompt_studio_tool`` is nullable) to preserve legacy behaviour for
orphan rows.
Shared access to the parent workflow -- direct, via group, or org-wide --
grants read only. Admits owners, co-owners, org admins and service
accounts, via :func:`is_workflow_mutator`. Requires the resource to carry
a ``workflow`` FK.

``create`` is handled separately from the rest: it is collection-level, so
DRF never calls ``get_object()`` and ``IsParentWorkflowOwner`` cannot run.
"""

def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if _is_service_account(request):
return True
owner_resource = obj.prompt_studio_tool or obj
if _is_resource_owner(request.user, owner_resource):
return True
return _is_organization_admin(request)
mutation_denied_message = (
"Only the workflow owner or an organization admin can change this."
)

def get_permissions(self) -> list[Any]:
if self.action in ("update", "partial_update", "destroy"):
return [IsParentWorkflowOwner()]
return list(super().get_permissions())
Comment on lines +167 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 4 — Security] — The gate validates the current parent, never the target parent, and workflow is writable.

IsParentWorkflowOwner.has_object_permission resolves ownership from obj.workflow — the parent the row has now. WorkflowEndpointSerializer is fields = "__all__" with only workflow_name marked read-only, so workflow itself is a writable FK.

Failure mode: the owner of workflow A sends PATCH /workflow/endpoint/<A-endpoint>/ with {"workflow": "<B-id>"}. The object check passes (they do own A), then the save reassigns the row to B, owned by a different user in the same org. B's owner now executes against A's connector instance, bucket, or table.

That is the customer-reported bug — a shared user repointing another team's workflow at a different output folder — still reachable after the fix that targets it. The same shape applies to the cloud companion: RuleEngineSerializer.read_only_fields is ["id", "created_by", "modified_by"] and SettingsSerializer is fields = "__all__", so both HITL viewsets inherit it.

Fix: mark workflow read-only on WorkflowEndpointSerializer (and on the two cloud HITL serializers), or validate in the mixin that validated_data.get("workflow") equals instance.workflow on update.

Two notes in this class's favour, verified while checking: perform_create correctly fails closed on a missing workflow, and the MRO order is right at every current call site. The base-order fragility (a bare mixin overriding get_permissions/perform_create, which DRF also defines) is a trap for the next viewset that copies it, not a live bug — worth an __init_subclass__ assertion given this is now a cross-repo contract.

Confidence: High on the field config (verified); the reassignment PATCH was not executed against a live DB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chandrasekharan-zipstack Half of this held and half didn't — and the half that held is now closed at eight sites, not one.

The OSS half is disconfirmed. WorkflowEndpointSerializer.workflow is not writable: WorkflowEndpoint.workflow is editable=False (models.py:34), which DRF honours by forcing read_only=True. I checked by building the serializer rather than reading Meta — the only writable fields are configuration, connection_type and connector_instance_id. So the reparenting PATCH you describe can't land there. You'd flagged your own uncertainty on exactly this ("the reassignment PATCH was not executed against a live DB"); the field-config half is the part that was off.

The cloud half was live and is fixed (1d9c4f6a). Both RuleEngineSerializer.workflow and SettingsSerializer.workflow built read_only=False. One correction worth recording: only SettingsSerializer was actually exploitable — RuleEngineSerializer has a custom update() that assigns only percentage/rule_string/rule_json/rule_logic and never touches workflow. The guard there is defence-in-depth, not a closed hole, and I've said so in the commit so nobody removes it later believing it was load-bearing.

The class is wider than either of us listed. Sweeping by gate rather than by field found eight sites: RuleEngine, HITLSettings, ToolInstance, ProfileManager, APIKey (api and pipeline), ToolStudioPrompt, and WorkflowEndpoint — the last safe only by that editable=False. All closed across 1d9c4f6a / 28cfde6 / ac31510 / c67c55c.

Two of those are worth your attention:

  • APIKeySerializer.api/.pipeline were writable on PATCH. create spends ~60 lines refusing a path-vs-body mismatch; update had nothing, so a live key could be retargeted onto a deployment the caller doesn't own.
  • The eighth site was an alias of a field I'd already guarded in the same file. ToolInstance.workflow's attname is workflow_id, and the serializer declares a writable workflow_id alias. validate_workflow fires only on the key workflow; a body of {"workflow_id": ...} skipped it entirely and DRF's setattr(instance, "workflow_id", uuid) wrote the FK column directly.

That last one is the argument for your __init_subclass__ suggestion, or better: hoisting the check into WorkflowOwnerMutationMixin so it covers attname aliases. Enumerating this class by field name provably cannot close it. I did not do that refactor — it touches eight viewsets across both repos and I didn't want it unreviewed at the end of an automated run. Recommended in the commit message.


def perform_create(self, serializer: Any) -> None:
# Fails closed: this mixin only guards resources that carry a parent
# workflow, so a payload without one cannot be authorised at all.
workflow = serializer.validated_data.get("workflow")
if not workflow or not is_workflow_mutator(self.request, workflow):
raise PermissionDenied(self.mutation_denied_message)
serializer.save()
Comment thread
greptile-apps[bot] marked this conversation as resolved.


class IsParentDeploymentOwner(permissions.BasePermission):
Expand All @@ -174,7 +185,7 @@ class IsParentDeploymentOwner(permissions.BasePermission):
An ``APIKey`` is not a membership resource, so its access is inherited
from the parent ``APIDeployment`` or ``Pipeline`` (both nullable — exactly
one is set). Admits the parent's owner (creator + co-owners), org admin,
or service account -- mirrors ``IsParentToolOwner`` (UN-2202). Falls back
or service account -- mirrors ``IsParentWorkflowOwner`` (UN-2202). Falls back
to the key's own ``created_by`` when both parents are null.

``obj`` may also be the parent itself. ``create`` is a collection-level
Expand Down
46 changes: 36 additions & 10 deletions backend/permissions/tests/test_owner_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
from django.test import TestCase
from permissions.roles import ResourceRole
from rest_framework import status
from rest_framework.parsers import JSONParser
from rest_framework.request import Request as DRFRequest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory, force_authenticate
from rest_framework.views import APIView
from workflow_manager.workflow_v2.models.workflow import Workflow
from workflow_manager.workflow_v2.views import WorkflowViewSet

from permissions.membership_serializers import AddOwnerSerializer
from permissions.permission import IsParentToolOwner
from prompt_studio.permission import ParentToolAccess
from permissions.tests.base import (
RESOURCE_SPECS,
CoOwnerOrgTestMixin,
Expand Down Expand Up @@ -323,10 +325,12 @@ def test_notification_failure_does_not_break_add(self) -> None:
self.assertIn(self.coowner.pk, owner_ids)


class IsParentToolOwnerTests(CoOwnerOrgTestMixin, TestCase):
"""``IsParentToolOwner`` inherits access from the parent ``CustomTool``
(owner/co-owner/admin/service-account allow; viewer/outsider deny) and falls
back to the object's own ``created_by`` when there is no parent tool.
class ParentToolAccessTests(CoOwnerOrgTestMixin, TestCase):
"""``ParentToolAccess`` inherits access from the parent ``CustomTool``.

A Prompt Studio project is shared for collaboration, so a viewer manages
its profiles alongside its owner; only an outsider is refused. Falls back
to the object's own ``created_by`` when there is no parent tool.
"""

def setUp(self) -> None:
Expand All @@ -346,7 +350,7 @@ def setUp(self) -> None:
def _perm(self, user: User, obj: object) -> bool:
request = APIRequestFactory().get("/")
request.user = user
return IsParentToolOwner().has_object_permission(request, APIView(), obj)
return ParentToolAccess().has_object_permission(request, APIView(), obj)

def test_parent_tool_owners_admin_service_account_allowed(self) -> None:
child = SimpleNamespace(prompt_studio_tool=self.tool)
Expand All @@ -356,17 +360,39 @@ def test_parent_tool_owners_admin_service_account_allowed(self) -> None:
self.assertTrue(self._perm(self.admin, child))
self.assertTrue(self._perm(svc, child))

def test_parent_tool_viewer_and_outsider_denied(self) -> None:
def test_parent_tool_viewer_allowed_outsider_denied(self) -> None:
# The collaboration rule: a shared viewer manages the project's
# profiles; someone with no access to the project does not.
child = SimpleNamespace(prompt_studio_tool=self.tool)
self.assertFalse(self._perm(self.viewer, child))
self.assertTrue(self._perm(self.viewer, child))
self.assertFalse(self._perm(self.outsider, child))

def test_null_parent_falls_back_to_object_owner(self) -> None:
def test_null_parent_falls_back_to_object_creator(self) -> None:
# No parent tool → access derives from the object's own ``created_by``.
orphan = SimpleNamespace(prompt_studio_tool=None, created_by=self.owner)
orphan = SimpleNamespace(
prompt_studio_tool=None, created_by_id=self.owner.pk
)
self.assertTrue(self._perm(self.owner, orphan))
self.assertFalse(self._perm(self.coowner, orphan))

def test_create_resolves_the_parent_from_the_payload(self) -> None:
# ``create`` is collection-level, so DRF never calls get_object();
# the parent is read from the request body instead.
def can_create(user: User, tool_id: object) -> bool:
# A DRF Request, not the raw WSGI one: the gate reads ``.data``.
raw = APIRequestFactory().post(
"/", {"prompt_studio_tool": str(tool_id)}, format="json"
)
request = DRFRequest(raw, parsers=[JSONParser()])
request.user = user
return ParentToolAccess().has_permission(
request, SimpleNamespace(action="create")
)

self.assertTrue(can_create(self.owner, self.tool.tool_id))
self.assertTrue(can_create(self.viewer, self.tool.tool_id))
self.assertFalse(can_create(self.outsider, self.tool.tool_id))


class AdapterShareOwnerExemptionTests(CoOwnerOrgTestMixin, TestCase):
"""A co-owner keeps their default-adapter link when a share-axis change
Expand Down
174 changes: 174 additions & 0 deletions backend/permissions/tests/test_shared_user_gates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""What a shared user may and may not do on someone else's resource (UN-2868).

Sharing is not one rule. A Prompt Studio project is shared *for
collaboration* -- prompts, settings and LLM profiles stay editable. Every
other resource is shared *for use*. On all of them, renaming, deleting and
changing who else has access stay with the owner.

These exercise the real viewsets through DRF's request factory, so a gate
that exists only in a permission class -- and never reaches the route -- is
still caught.
"""

from typing import Any

from account_v2.models import User
from django.test import TestCase
from permissions.roles import ResourceRole
from permissions.tests.base import CoOwnerOrgTestMixin
from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory, force_authenticate
from tool_instance_v2.views import ToolInstanceViewSet
from workflow_manager.endpoint_v2.models import WorkflowEndpoint
from workflow_manager.endpoint_v2.views import WorkflowEndpointViewSet
from workflow_manager.workflow_v2.models.workflow import Workflow


class SharedWorkflowEndpointTests(CoOwnerOrgTestMixin, TestCase):
"""A workflow is shared for use: its connector config is owner-only."""

def setUp(self) -> None:
self._seed_org()
self.workflow = Workflow.objects.create(
workflow_name="wf-endpoint", organization=self.org, created_by=self.owner
)
self.workflow.memberships.create(user=self.owner, role=ResourceRole.OWNER)
self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER)
self.endpoint = WorkflowEndpoint.objects.create(
workflow=self.workflow,
endpoint_type=WorkflowEndpoint.EndpointType.DESTINATION,
connection_type=WorkflowEndpoint.ConnectionType.FILESYSTEM,
)
self.factory = APIRequestFactory()

def _patch(self, actor: User) -> Response:
view = WorkflowEndpointViewSet.as_view({"patch": "partial_update"})
request = self.factory.patch(
"/x/", {"configuration": {"path": "/changed"}}, format="json"
)
force_authenticate(request, user=actor)
return view(request, pk=str(self.endpoint.pk))

def _delete(self, actor: User) -> Response:
view = WorkflowEndpointViewSet.as_view({"delete": "destroy"})
request = self.factory.delete("/x/")
force_authenticate(request, user=actor)
return view(request, pk=str(self.endpoint.pk))

def _read(self, actor: User) -> Response:
view = WorkflowEndpointViewSet.as_view({"get": "retrieve"})
request = self.factory.get("/x/")
force_authenticate(request, user=actor)
return view(request, pk=str(self.endpoint.pk))

def test_shared_viewer_cannot_change_connector_config(self) -> None:
self.assertEqual(self._patch(self.viewer).status_code, status.HTTP_403_FORBIDDEN)

def test_shared_viewer_cannot_delete_the_endpoint(self) -> None:
self.assertEqual(
self._delete(self.viewer).status_code, status.HTTP_403_FORBIDDEN
)
self.assertTrue(WorkflowEndpoint.objects.filter(pk=self.endpoint.pk).exists())

def test_shared_viewer_can_still_read_it(self) -> None:
# Refusing the write must not also hide the resource.
self.assertEqual(self._read(self.viewer).status_code, status.HTTP_200_OK)

def test_owner_and_co_owner_can_change_it(self) -> None:
self.workflow.memberships.create(user=self.coowner, role=ResourceRole.OWNER)
for actor in (self.owner, self.coowner):
self.assertEqual(self._patch(actor).status_code, status.HTTP_200_OK)

def test_a_user_with_no_access_gets_404_not_403(self) -> None:
# 403 would confirm the endpoint exists to someone who cannot see it.
self.assertEqual(
self._patch(self.outsider).status_code, status.HTTP_404_NOT_FOUND
)


class SharedWorkflowToolInstanceTests(CoOwnerOrgTestMixin, TestCase):
"""Attaching a tool mutates the workflow -- and activates it."""

def setUp(self) -> None:
self._seed_org()
self.workflow = Workflow.objects.create(
workflow_name="wf-tools",
organization=self.org,
created_by=self.owner,
is_active=False,
)
self.workflow.memberships.create(user=self.owner, role=ResourceRole.OWNER)
self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER)
self.factory = APIRequestFactory()

def _create(self, actor: User) -> Response:
view = ToolInstanceViewSet.as_view({"post": "create"})
request = self.factory.post(
"/x/",
{"workflow_id": str(self.workflow.pk), "tool_id": "tool-uid"},
format="json",
)
force_authenticate(request, user=actor)
return view(request)

def test_shared_viewer_cannot_add_a_tool(self) -> None:
response = self._create(self.viewer)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_a_user_with_no_access_gets_404(self) -> None:
self.assertEqual(
self._create(self.outsider).status_code, status.HTTP_404_NOT_FOUND
)


class SharedPromptStudioProjectTests(CoOwnerOrgTestMixin, TestCase):
"""Prompt Studio is shared for collaboration; only the name is owner-only."""

def setUp(self) -> None:
self._seed_org()
from prompt_studio.prompt_studio_core_v2.models import CustomTool

self.tool = CustomTool.objects.create(
tool_name="ps-project",
description="collaboration test",
organization=self.org,
created_by=self.owner,
)
self.tool.memberships.create(user=self.owner, role=ResourceRole.OWNER)
self.tool.memberships.create(user=self.viewer, role=ResourceRole.VIEWER)
self.factory = APIRequestFactory()

def _patch(self, actor: User, payload: dict[str, Any]) -> Response:
from prompt_studio.prompt_studio_core_v2.views import PromptStudioCoreView

view = PromptStudioCoreView.as_view({"patch": "partial_update"})
request = self.factory.patch("/x/", payload, format="json")
force_authenticate(request, user=actor)
return view(request, pk=str(self.tool.pk))

def test_shared_user_cannot_rename_the_project(self) -> None:
response = self._patch(self.viewer, {"tool_name": "renamed-by-viewer"})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.tool.refresh_from_db()
self.assertEqual(self.tool.tool_name, "ps-project")

def test_shared_user_can_change_a_settings_field(self) -> None:
# Same endpoint as the rename, so the gate has to be per-field.
response = self._patch(self.viewer, {"preamble": "set by a collaborator"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.tool.refresh_from_db()
self.assertEqual(self.tool.preamble, "set by a collaborator")

def test_owner_can_rename(self) -> None:
response = self._patch(self.owner, {"tool_name": "renamed-by-owner"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.tool.refresh_from_db()
self.assertEqual(self.tool.tool_name, "renamed-by-owner")

def test_resending_the_same_name_is_not_a_rename(self) -> None:
# A settings PATCH that echoes the current name must not be refused.
response = self._patch(
self.viewer, {"tool_name": "ps-project", "postamble": "echoed"}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
Loading
Loading