Skip to content

Commit ffe8b59

Browse files
committed
✨(backend) delete accesses and invitations on move when scope changes
When a document is moved outside its current permission scope (root document, cross-tree move, or promotion to root), its direct accesses and pending invitations are now deleted server-side within the same atomic transaction as the move itself. This ensures consistency: if the move fails, deletions are rolled back. Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
1 parent 1c07208 commit ffe8b59

2 files changed

Lines changed: 298 additions & 6 deletions

File tree

src/backend/core/api/viewsets.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -947,8 +947,8 @@ def move(self, request, *args, **kwargs):
947947
"as a child to this target document."
948948
)
949949
elif target_document.is_root():
950-
owner_accesses = document.get_root().accesses.filter(
951-
role=models.RoleChoices.OWNER
950+
owner_accesses = list(
951+
document.get_root().accesses.filter(role=models.RoleChoices.OWNER)
952952
)
953953
elif not target_document.get_parent().get_abilities(user).get("move"):
954954
message = (
@@ -962,6 +962,30 @@ def move(self, request, *args, **kwargs):
962962
status=status.HTTP_400_BAD_REQUEST,
963963
)
964964

965+
# A move changes the document's permission scope in any of these cases:
966+
# - it is currently a root (it carries its own scope),
967+
# - it is moving into a different tree (different current root than target's),
968+
# - it is being promoted to root as a sibling of its own current root.
969+
# In all these cases, direct accesses and pending invitations must be wiped so
970+
# the document inherits the new scope. Deletions and the move share the same
971+
# atomic transaction, so a failure rolls everything back.
972+
becomes_sibling_root = (
973+
position
974+
not in [
975+
enums.MoveNodePositionChoices.FIRST_CHILD,
976+
enums.MoveNodePositionChoices.LAST_CHILD,
977+
]
978+
and target_document.is_root()
979+
)
980+
scope_changes = (
981+
document.is_root()
982+
or becomes_sibling_root
983+
or document.get_root() != target_document.get_root()
984+
)
985+
if scope_changes:
986+
document.accesses.all().delete()
987+
document.invitations.all().delete()
988+
965989
document.move(target_document, pos=position)
966990

967991
# Make sure we have at least one owner

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

Lines changed: 272 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,10 @@ def test_api_documents_move_authenticated_no_owner_user_and_team():
237237

238238
document.refresh_from_db()
239239
assert list(document.get_children()) == [child]
240-
assert document.accesses.count() == 3
240+
241+
assert document.accesses.count() == 2
241242
assert document.accesses.get(user__isnull=False, role="owner").user == parent_owner
242243
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
243-
assert document.accesses.get(role="administrator").user == user
244244

245245

246246
def test_api_documents_move_authenticated_no_owner_same_user():
@@ -304,8 +304,10 @@ def test_api_documents_move_authenticated_no_owner_same_team():
304304

305305
document.refresh_from_db()
306306
assert list(document.get_children()) == [child]
307-
assert document.accesses.count() == 2
308-
assert document.accesses.get(user__isnull=False, role="administrator").user == user
307+
308+
# The user's direct administrator access was wiped during the cross-tree move;
309+
# only the "lasuite" team remains, re-added as owner from the previous root.
310+
assert document.accesses.count() == 1
309311
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
310312

311313

@@ -438,3 +440,269 @@ def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
438440
# Verify that the document has not moved
439441
document.refresh_from_db()
440442
assert document.is_root() is True
443+
444+
445+
def test_api_documents_move_root_deletes_accesses_and_invitations():
446+
"""
447+
Moving a root document should automatically delete all its direct accesses and
448+
invitations so it inherits the target's permissions.
449+
"""
450+
user = factories.UserFactory()
451+
client = APIClient()
452+
client.force_login(user)
453+
454+
other_user = factories.UserFactory()
455+
document = factories.DocumentFactory(
456+
users=[(user, "owner"), (other_user, "editor")]
457+
)
458+
factories.InvitationFactory(document=document)
459+
factories.InvitationFactory(document=document)
460+
461+
target = factories.DocumentFactory(users=[(user, "owner")])
462+
463+
assert document.is_root()
464+
assert document.accesses.count() == 2
465+
assert document.invitations.count() == 2
466+
467+
response = client.post(
468+
f"/api/v1.0/documents/{document.id!s}/move/",
469+
data={"target_document_id": str(target.id), "position": "first-child"},
470+
)
471+
472+
assert response.status_code == 200
473+
assert response.json() == {"message": "Document moved successfully."}
474+
475+
document.refresh_from_db()
476+
assert document.is_child_of(target)
477+
assert document.accesses.count() == 0
478+
assert document.invitations.count() == 0
479+
480+
481+
def test_api_documents_move_cross_tree_deletes_accesses_and_invitations():
482+
"""
483+
Moving a non-root document to a different tree should delete its direct accesses
484+
and invitations because it is leaving its current permission scope.
485+
"""
486+
user = factories.UserFactory()
487+
client = APIClient()
488+
client.force_login(user)
489+
490+
other_user = factories.UserFactory()
491+
source_root = factories.DocumentFactory(users=[(user, "owner")])
492+
document = factories.DocumentFactory(
493+
parent=source_root, users=[(user, "owner"), (other_user, "editor")]
494+
)
495+
factories.InvitationFactory(document=document)
496+
497+
target_root = factories.DocumentFactory(users=[(user, "owner")])
498+
target = factories.DocumentFactory(parent=target_root, users=[(user, "owner")])
499+
500+
assert not document.is_root()
501+
assert document.get_root() != target.get_root()
502+
assert document.accesses.count() == 2
503+
assert document.invitations.count() == 1
504+
505+
response = client.post(
506+
f"/api/v1.0/documents/{document.id!s}/move/",
507+
data={"target_document_id": str(target.id), "position": "first-child"},
508+
)
509+
510+
assert response.status_code == 200
511+
assert response.json() == {"message": "Document moved successfully."}
512+
513+
document.refresh_from_db()
514+
assert document.is_child_of(target)
515+
assert document.accesses.count() == 0
516+
assert document.invitations.count() == 0
517+
518+
519+
def test_api_documents_move_same_tree_keeps_accesses_and_invitations():
520+
"""
521+
Moving a non-root document within the same tree should preserve its direct
522+
accesses and invitations since it stays in the same permission scope.
523+
"""
524+
user = factories.UserFactory()
525+
client = APIClient()
526+
client.force_login(user)
527+
528+
other_user = factories.UserFactory()
529+
root = factories.DocumentFactory(users=[(user, "owner")])
530+
document = factories.DocumentFactory(
531+
parent=root, users=[(user, "owner"), (other_user, "editor")]
532+
)
533+
factories.InvitationFactory(document=document)
534+
target = factories.DocumentFactory(parent=root, users=[(user, "owner")])
535+
536+
assert not document.is_root()
537+
assert document.get_root() == target.get_root()
538+
assert document.accesses.count() == 2
539+
assert document.invitations.count() == 1
540+
541+
response = client.post(
542+
f"/api/v1.0/documents/{document.id!s}/move/",
543+
data={"target_document_id": str(target.id), "position": "first-child"},
544+
)
545+
546+
assert response.status_code == 200
547+
assert response.json() == {"message": "Document moved successfully."}
548+
549+
document.refresh_from_db()
550+
assert document.is_child_of(target)
551+
assert document.accesses.count() == 2
552+
assert document.invitations.count() == 1
553+
554+
555+
@pytest.mark.parametrize("position", ["first-sibling", "last-sibling", "left", "right"])
556+
def test_api_documents_move_sub_document_to_root_deletes_accesses_and_invitations(
557+
position,
558+
):
559+
"""
560+
Moving a sub-document to root level (as a sibling of a root document) changes its
561+
permission scope. Its direct accesses and invitations must be deleted.
562+
"""
563+
user = factories.UserFactory()
564+
client = APIClient()
565+
client.force_login(user)
566+
567+
other_user = factories.UserFactory()
568+
parent = factories.DocumentFactory(users=[(user, "owner")])
569+
document = factories.DocumentFactory(
570+
parent=parent, users=[(user, "owner"), (other_user, "editor")]
571+
)
572+
factories.InvitationFactory(document=document)
573+
574+
# target is a root document; moving as its sibling promotes document to root level
575+
target = factories.DocumentFactory(users=[(user, "owner")])
576+
577+
assert not document.is_root()
578+
assert document.accesses.count() == 2
579+
assert document.invitations.count() == 1
580+
581+
response = client.post(
582+
f"/api/v1.0/documents/{document.id!s}/move/",
583+
data={"target_document_id": str(target.id), "position": position},
584+
)
585+
586+
assert response.status_code == 200
587+
document.refresh_from_db()
588+
assert document.is_root()
589+
# Direct accesses and invitations are wiped; the backend then ensures at least one
590+
# owner exists by inheriting the owner(s) from the previous root.
591+
assert document.accesses.count() == 1
592+
assert document.accesses.filter(role="owner").exists()
593+
assert document.invitations.count() == 0
594+
595+
596+
@pytest.mark.parametrize("position", ["first-sibling", "last-sibling", "left", "right"])
597+
def test_api_documents_move_sub_document_as_sibling_of_its_own_root_deletes_accesses(
598+
position,
599+
):
600+
"""
601+
Moving a sub-document as a sibling of its current root promotes it to a
602+
new root. Even though before the move both share the same root, the document is
603+
leaving its permission scope and its direct accesses/invitations must be deleted.
604+
"""
605+
user = factories.UserFactory()
606+
client = APIClient()
607+
client.force_login(user)
608+
609+
other_user = factories.UserFactory()
610+
root = factories.DocumentFactory(users=[(user, "owner")])
611+
document = factories.DocumentFactory(
612+
parent=root, users=[(user, "owner"), (other_user, "editor")]
613+
)
614+
factories.InvitationFactory(document=document)
615+
616+
assert not document.is_root()
617+
assert document.get_root() == root
618+
assert document.accesses.count() == 2
619+
assert document.invitations.count() == 1
620+
621+
response = client.post(
622+
f"/api/v1.0/documents/{document.id!s}/move/",
623+
data={"target_document_id": str(root.id), "position": position},
624+
)
625+
626+
assert response.status_code == 200
627+
document.refresh_from_db()
628+
assert document.is_root()
629+
# Original accesses wiped; owner re-inherited from previous root ensures non-orphaned.
630+
assert document.accesses.count() == 1
631+
assert document.accesses.filter(role="owner").exists()
632+
assert document.invitations.count() == 0
633+
634+
635+
@pytest.mark.parametrize("position", ["first-sibling", "last-sibling", "left", "right"])
636+
def test_api_documents_move_root_as_sibling_of_root_preserves_owner(position):
637+
"""
638+
Moving a root document as sibling of another root, the owners
639+
collected from the previous root (which is the document itself) must survive the
640+
pre-move access deletion, so the document keeps at least one owner afterwards.
641+
"""
642+
user = factories.UserFactory()
643+
client = APIClient()
644+
client.force_login(user)
645+
646+
other_user = factories.UserFactory()
647+
document = factories.DocumentFactory(
648+
users=[(user, "owner"), (other_user, "editor")]
649+
)
650+
factories.InvitationFactory(document=document)
651+
652+
target = factories.DocumentFactory(users=[(user, "owner")])
653+
654+
assert document.is_root()
655+
assert target.is_root()
656+
657+
response = client.post(
658+
f"/api/v1.0/documents/{document.id!s}/move/",
659+
data={"target_document_id": str(target.id), "position": position},
660+
)
661+
662+
assert response.status_code == 200
663+
document.refresh_from_db()
664+
assert document.is_root()
665+
# The original editor access and invitation are wiped; the previous root owner
666+
# is re-added so the document still has at least one owner.
667+
assert document.accesses.count() == 1
668+
assert document.accesses.get(role="owner").user == user
669+
assert document.invitations.count() == 0
670+
671+
672+
def test_api_documents_move_scope_change_deletion_is_atomic(monkeypatch):
673+
"""
674+
When accesses/invitations are to be deleted (root or cross-tree move), both
675+
deletions and the tree move are atomic: if the move fails, deletions roll back.
676+
"""
677+
user = factories.UserFactory()
678+
client = APIClient()
679+
client.force_login(user)
680+
681+
other_user = factories.UserFactory()
682+
document = factories.DocumentFactory(
683+
users=[(user, "owner"), (other_user, "editor")]
684+
)
685+
factories.InvitationFactory(document=document)
686+
target = factories.DocumentFactory(users=[(user, "owner")])
687+
688+
assert document.is_root()
689+
assert document.accesses.count() == 2
690+
assert document.invitations.count() == 1
691+
692+
# Force Document.move to fail *after* the deletion block has already run,
693+
# so we actually exercise the rollback path rather than bailing out earlier.
694+
def failing_move(self, *args, **kwargs):
695+
raise RuntimeError("Simulated move failure for atomicity test")
696+
697+
monkeypatch.setattr(models.Document, "move", failing_move)
698+
699+
with pytest.raises(RuntimeError, match="Simulated move failure"):
700+
client.post(
701+
f"/api/v1.0/documents/{document.id!s}/move/",
702+
data={"target_document_id": str(target.id), "position": "first-sibling"},
703+
)
704+
705+
# Accesses and invitations must still exist due to transaction rollback
706+
document.refresh_from_db()
707+
assert document.accesses.count() == 2
708+
assert document.invitations.count() == 1

0 commit comments

Comments
 (0)