Skip to content

Commit 8780fc1

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

File tree

2 files changed

+254
-10
lines changed

2 files changed

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

0 commit comments

Comments
 (0)