From 26e0e0575450866d03e209ae1c6b693f9624c1eb Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 18:57:05 +0200 Subject: [PATCH] test --- backend/drive/storage_layout.py | 22 ++++++++++++ backend/drive/test_trash_isolation.py | 48 +++++++++++++++++++++++++++ backend/drive/views_items.py | 5 ++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/backend/drive/storage_layout.py b/backend/drive/storage_layout.py index fd02b98..e787ca8 100644 --- a/backend/drive/storage_layout.py +++ b/backend/drive/storage_layout.py @@ -137,6 +137,23 @@ def trash_location(item): return f'.gooddrive-trash/{item.pk}/{item.name}' +def cleanup_empty_trash_container(item): + """Remove only this item's empty UUID container after restoration. + + A non-empty directory is deliberately kept: it may contain unrelated data + and must never be removed recursively. + """ + container = disk_path(f'.gooddrive-trash/{item.pk}') + try: + container.rmdir() + except FileNotFoundError: + pass + except OSError: + # Non-empty or inaccessible: leave it intact. A later trash operation + # will refuse to overwrite any content that remains here. + pass + + def move_to_trash(item, *, migrate_existing=False): """Atomic disk/SQL move. Preserve the original name/parent for restoration.""" from django.utils import timezone @@ -155,6 +172,11 @@ def move_to_trash(item, *, migrate_existing=False): raise FileNotFoundError('Élément absent du stockage, déplacement impossible.') if target.exists() or target.is_symlink(): raise NameConflict('La destination de la corbeille est déjà utilisée.') + # Previous versions left the UUID container behind when restoring a file. + # Remove only empty legacy containers so the same item can be trashed again. + cleanup_empty_trash_container(item) + if target.parent.exists() or target.parent.is_symlink(): + raise NameConflict('Le dossier de corbeille de cet élément contient encore des données.') target.parent.mkdir(parents=True, exist_ok=False) moved = False try: diff --git a/backend/drive/test_trash_isolation.py b/backend/drive/test_trash_isolation.py index e070265..f6379b8 100644 --- a/backend/drive/test_trash_isolation.py +++ b/backend/drive/test_trash_isolation.py @@ -75,6 +75,54 @@ class TrashIsolationTests(TestCase): child.refresh_from_db() self.assertEqual(disk_path(child.storage_location).read_bytes(), b'present') + def test_file_can_be_trashed_again_after_restore(self): + item = self.new_file(name='cycle.txt', content=b'never lose me') + container = disk_path(f'.gooddrive-trash/{item.pk}') + for _ in range(3): + response = self.client.post(f'/api/drive/items/{item.pk}/trash/') + self.assertEqual(response.status_code, 200, response.data) + item.refresh_from_db() + self.assertTrue(container.is_dir()) + self.assertEqual(disk_path(item.storage_location).read_bytes(), b'never lose me') + response = self.client.post(f'/api/drive/items/{item.pk}/restore/') + self.assertEqual(response.status_code, 200, response.data) + item.refresh_from_db() + self.assertIsNone(item.trashed_at) + self.assertFalse(container.exists(), 'La restauration doit nettoyer le dossier ID vide') + self.assertEqual(disk_path(item.storage_location).read_bytes(), b'never lose me') + + def test_stale_empty_id_folder_from_old_restore_is_reused(self): + item = self.new_file(name='existing.txt') + container = disk_path(f'.gooddrive-trash/{item.pk}') + container.mkdir(parents=True) + response = self.client.post(f'/api/drive/items/{item.pk}/trash/') + self.assertEqual(response.status_code, 200, response.data) + item.refresh_from_db() + self.assertEqual(disk_path(item.storage_location).read_bytes(), b'original') + + def test_nonempty_legacy_container_is_never_overwritten(self): + item = self.new_file(name='important.txt', content=b'original') + container = disk_path(f'.gooddrive-trash/{item.pk}') + container.mkdir(parents=True) + (container / 'untracked.txt').write_bytes(b'KEEP') + response = self.client.post(f'/api/drive/items/{item.pk}/trash/') + self.assertEqual(response.status_code, 409) + self.assertEqual((container / 'untracked.txt').read_bytes(), b'KEEP') + item.refresh_from_db() + self.assertIsNone(item.trashed_at) + self.assertEqual(disk_path(item.storage_location).read_bytes(), b'original') + + def test_folder_can_be_trashed_again_after_restore(self): + response = self.client.post('/api/drive/folders/', {'name': 'Recettes', 'space': self.space.pk}, format='json') + self.assertEqual(response.status_code, 201, response.data) + item = DriveItem.objects.get(pk=response.data['id']) + container = disk_path(f'.gooddrive-trash/{item.pk}') + for _ in range(2): + self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/trash/').status_code, 200) + self.assertTrue(container.is_dir()) + self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/restore/').status_code, 200) + self.assertFalse(container.exists()) + def test_migrate_existing_trashed_without_resetting_retention(self): item = self.new_file() when = timezone.now() - timedelta(days=4) diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index a5b76a5..bc0ebb5 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -10,7 +10,7 @@ from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, from .permissions import has_space_right, item_rights from .quotas import quota_state from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination -from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, NameConflict +from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, cleanup_empty_trash_container, NameConflict from .trash_cleanup import delete_trashed_item, has_active_uploads_in_tree @@ -209,6 +209,9 @@ def restore_item(request,item_id): relocate_item(item, parent, name) item.trashed_at = None item.save(update_fields=['trashed_at','updated_at']) + # The restored item is no longer in its .gooddrive-trash// folder. + # Leaving that empty directory prevents the next trash operation. + cleanup_empty_trash_container(item) except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc: return error(f'Restauration impossible : {exc}',409) return Response(item_info(item,request.user))