Skip to content

Commit 2c4a906

Browse files
committed
fixup! 🥅(backend) link role could be updated when restricted document
1 parent 0de02a9 commit 2c4a906

File tree

2 files changed

+260
-10
lines changed

2 files changed

+260
-10
lines changed

src/backend/core/api/serializers.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
486486
We expose it separately from document in order to simplify and secure access control.
487487
"""
488488

489+
link_reach = serializers.ChoiceField(
490+
choices=models.LinkReachChoices.choices, required=True
491+
)
492+
489493
class Meta:
490494
model = models.Document
491495
fields = [
@@ -494,19 +498,62 @@ class Meta:
494498
]
495499

496500
def validate(self, attrs):
497-
"""Validate that link_role is compatible with link_reach."""
498-
link_reach = attrs.get("link_reach", self.instance.link_reach)
501+
"""Validate that link_role and link_reach are compatible using get_select_options."""
502+
link_reach = attrs.get("link_reach")
499503
link_role = attrs.get("link_role")
500504

501-
# If link_reach is restricted, link_role should not be set to anything meaningful
502-
if link_reach == models.LinkReachChoices.RESTRICTED and link_role in [
503-
models.LinkRoleChoices.READER,
504-
models.LinkRoleChoices.EDITOR,
505-
]:
505+
if not link_reach:
506+
raise serializers.ValidationError(
507+
{"link_reach": _("This field is required.")}
508+
)
509+
510+
# Get available options based on ancestors' link definition
511+
available_options = models.LinkReachChoices.get_select_options(
512+
**self.instance.ancestors_link_definition
513+
)
514+
515+
# Validate link_reach is allowed
516+
if link_reach not in available_options:
506517
msg = _(
507-
"Cannot set link_role when link_reach is 'restricted'. Change link_reach first."
518+
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
519+
)
520+
raise serializers.ValidationError(
521+
{"link_reach": msg % {"link_reach": link_reach}}
522+
)
523+
524+
# Validate link_role is compatible with link_reach
525+
allowed_roles = available_options[link_reach]
526+
527+
if link_reach == models.LinkReachChoices.RESTRICTED:
528+
# For restricted reach, link_role is ignored but
529+
# we shouldn't allow meaningful roles to be set
530+
if link_role is not None and link_role in [
531+
models.LinkRoleChoices.READER,
532+
models.LinkRoleChoices.EDITOR,
533+
]:
534+
msg = _(
535+
"Cannot set link_role when link_reach is 'restricted'. "
536+
"Link role must be null for restricted reach."
537+
)
538+
raise serializers.ValidationError({"link_role": msg})
539+
# For non-restricted reach, validate link_role is in allowed roles
540+
elif link_role is not None and link_role not in allowed_roles:
541+
msg = _(
542+
"Link role '%(link_role)s' is not allowed for link reach '%(link_reach)s'. "
543+
"Allowed roles: %(allowed_roles)s"
544+
)
545+
raise serializers.ValidationError(
546+
{
547+
"link_role": msg
548+
% {
549+
"link_role": link_role,
550+
"link_reach": link_reach,
551+
"allowed_roles": ", ".join(allowed_roles)
552+
if allowed_roles
553+
else "none",
554+
}
555+
}
508556
)
509-
raise serializers.ValidationError({"link_role": msg})
510557

511558
return attrs
512559

src/backend/core/tests/documents/test_api_documents_link_configuration.py

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,11 @@ def test_api_documents_link_configuration_update_role_restricted_forbidden():
184184
document=document, user=user, role=models.RoleChoices.OWNER
185185
)
186186

187-
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
187+
# Try to set a meaningful role on a restricted document
188+
new_data = {
189+
"link_reach": models.LinkReachChoices.RESTRICTED,
190+
"link_role": models.LinkRoleChoices.EDITOR,
191+
}
188192

189193
response = client.put(
190194
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
@@ -198,3 +202,202 @@ def test_api_documents_link_configuration_update_role_restricted_forbidden():
198202
"Cannot set link_role when link_reach is 'restricted'"
199203
in response.json()["link_role"][0]
200204
)
205+
206+
207+
def test_api_documents_link_configuration_update_link_reach_required():
208+
"""
209+
Test that link_reach is required when updating link configuration.
210+
"""
211+
user = factories.UserFactory()
212+
client = APIClient()
213+
client.force_login(user)
214+
215+
document = factories.DocumentFactory(
216+
link_reach=models.LinkReachChoices.PUBLIC,
217+
link_role=models.LinkRoleChoices.READER,
218+
)
219+
220+
factories.UserDocumentAccessFactory(
221+
document=document, user=user, role=models.RoleChoices.OWNER
222+
)
223+
224+
# Try to update without providing link_reach
225+
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
226+
227+
response = client.put(
228+
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
229+
new_data,
230+
format="json",
231+
)
232+
233+
assert response.status_code == 400
234+
assert "link_reach" in response.json()
235+
assert "This field is required" in response.json()["link_reach"][0]
236+
237+
238+
def test_api_documents_link_configuration_update_restricted_without_role_success(
239+
mock_reset_connections, # pylint: disable=redefined-outer-name
240+
):
241+
"""
242+
Test that setting link_reach to restricted without specifying link_role succeeds.
243+
"""
244+
user = factories.UserFactory()
245+
client = APIClient()
246+
client.force_login(user)
247+
248+
document = factories.DocumentFactory(
249+
link_reach=models.LinkReachChoices.PUBLIC,
250+
link_role=models.LinkRoleChoices.READER,
251+
)
252+
253+
factories.UserDocumentAccessFactory(
254+
document=document, user=user, role=models.RoleChoices.OWNER
255+
)
256+
257+
# Only specify link_reach, not link_role
258+
new_data = {
259+
"link_reach": models.LinkReachChoices.RESTRICTED,
260+
}
261+
262+
with mock_reset_connections(document.id):
263+
response = client.put(
264+
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
265+
new_data,
266+
format="json",
267+
)
268+
269+
assert response.status_code == 200
270+
document.refresh_from_db()
271+
assert document.link_reach == models.LinkReachChoices.RESTRICTED
272+
273+
274+
@pytest.mark.parametrize(
275+
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
276+
)
277+
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
278+
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
279+
reach,
280+
role,
281+
mock_reset_connections, # pylint: disable=redefined-outer-name
282+
):
283+
"""
284+
Test that setting non-restricted link_reach with valid link_role succeeds.
285+
"""
286+
user = factories.UserFactory()
287+
client = APIClient()
288+
client.force_login(user)
289+
290+
document = factories.DocumentFactory(
291+
link_reach=models.LinkReachChoices.RESTRICTED,
292+
link_role=models.LinkRoleChoices.READER,
293+
)
294+
295+
factories.UserDocumentAccessFactory(
296+
document=document, user=user, role=models.RoleChoices.OWNER
297+
)
298+
299+
new_data = {
300+
"link_reach": reach,
301+
"link_role": role,
302+
}
303+
304+
with mock_reset_connections(document.id):
305+
response = client.put(
306+
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
307+
new_data,
308+
format="json",
309+
)
310+
311+
assert response.status_code == 200
312+
document.refresh_from_db()
313+
assert document.link_reach == reach
314+
assert document.link_role == role
315+
316+
317+
def test_api_documents_link_configuration_update_with_ancestor_constraints():
318+
"""
319+
Test that link configuration respects ancestor constraints using get_select_options.
320+
This test may need adjustment based on the actual get_select_options implementation.
321+
"""
322+
user = factories.UserFactory()
323+
client = APIClient()
324+
client.force_login(user)
325+
326+
parent_document = factories.DocumentFactory(
327+
link_reach=models.LinkReachChoices.PUBLIC,
328+
link_role=models.LinkRoleChoices.READER,
329+
)
330+
331+
child_document = factories.DocumentFactory(
332+
parent=parent_document,
333+
link_reach=models.LinkReachChoices.PUBLIC,
334+
link_role=models.LinkRoleChoices.READER,
335+
)
336+
337+
factories.UserDocumentAccessFactory(
338+
document=child_document, user=user, role=models.RoleChoices.OWNER
339+
)
340+
341+
# Try to set child to PUBLIC when parent is RESTRICTED
342+
new_data = {
343+
"link_reach": models.LinkReachChoices.RESTRICTED,
344+
"link_role": models.LinkRoleChoices.READER,
345+
}
346+
347+
response = client.put(
348+
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
349+
new_data,
350+
format="json",
351+
)
352+
353+
assert response.status_code == 400
354+
assert "link_reach" in response.json()
355+
assert (
356+
"Link reach 'restricted' is not allowed based on parent"
357+
in response.json()["link_reach"][0]
358+
)
359+
360+
361+
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
362+
"""
363+
Test the specific validation logic that checks if link_role is allowed for link_reach.
364+
This tests the code section that validates allowed_roles from get_select_options.
365+
"""
366+
user = factories.UserFactory()
367+
client = APIClient()
368+
client.force_login(user)
369+
370+
parent_document = factories.DocumentFactory(
371+
link_reach=models.LinkReachChoices.AUTHENTICATED,
372+
link_role=models.LinkRoleChoices.EDITOR,
373+
)
374+
375+
child_document = factories.DocumentFactory(
376+
parent=parent_document,
377+
link_reach=models.LinkReachChoices.RESTRICTED,
378+
link_role=models.LinkRoleChoices.READER,
379+
)
380+
381+
factories.UserDocumentAccessFactory(
382+
document=child_document, user=user, role=models.RoleChoices.OWNER
383+
)
384+
385+
new_data = {
386+
"link_reach": models.LinkReachChoices.AUTHENTICATED,
387+
"link_role": models.LinkRoleChoices.READER, # This should be rejected
388+
}
389+
390+
response = client.put(
391+
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
392+
new_data,
393+
format="json",
394+
)
395+
396+
assert response.status_code == 400
397+
assert "link_role" in response.json()
398+
error_message = response.json()["link_role"][0]
399+
assert (
400+
"Link role 'reader' is not allowed for link reach 'authenticated'"
401+
in error_message
402+
)
403+
assert "Allowed roles: editor" in error_message

0 commit comments

Comments
 (0)