test
This commit is contained in:
parent
17afb70810
commit
26e0e05754
@ -137,6 +137,23 @@ def trash_location(item):
|
|||||||
return f'.gooddrive-trash/{item.pk}/{item.name}'
|
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):
|
def move_to_trash(item, *, migrate_existing=False):
|
||||||
"""Atomic disk/SQL move. Preserve the original name/parent for restoration."""
|
"""Atomic disk/SQL move. Preserve the original name/parent for restoration."""
|
||||||
from django.utils import timezone
|
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.')
|
raise FileNotFoundError('Élément absent du stockage, déplacement impossible.')
|
||||||
if target.exists() or target.is_symlink():
|
if target.exists() or target.is_symlink():
|
||||||
raise NameConflict('La destination de la corbeille est déjà utilisée.')
|
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)
|
target.parent.mkdir(parents=True, exist_ok=False)
|
||||||
moved = False
|
moved = False
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -75,6 +75,54 @@ class TrashIsolationTests(TestCase):
|
|||||||
child.refresh_from_db()
|
child.refresh_from_db()
|
||||||
self.assertEqual(disk_path(child.storage_location).read_bytes(), b'present')
|
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):
|
def test_migrate_existing_trashed_without_resetting_retention(self):
|
||||||
item = self.new_file()
|
item = self.new_file()
|
||||||
when = timezone.now() - timedelta(days=4)
|
when = timezone.now() - timedelta(days=4)
|
||||||
|
|||||||
@ -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 .permissions import has_space_right, item_rights
|
||||||
from .quotas import quota_state
|
from .quotas import quota_state
|
||||||
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination
|
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
|
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)
|
relocate_item(item, parent, name)
|
||||||
item.trashed_at = None
|
item.trashed_at = None
|
||||||
item.save(update_fields=['trashed_at','updated_at'])
|
item.save(update_fields=['trashed_at','updated_at'])
|
||||||
|
# The restored item is no longer in its .gooddrive-trash/<ID>/ folder.
|
||||||
|
# Leaving that empty directory prevents the next trash operation.
|
||||||
|
cleanup_empty_trash_container(item)
|
||||||
except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc:
|
except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc:
|
||||||
return error(f'Restauration impossible : {exc}',409)
|
return error(f'Restauration impossible : {exc}',409)
|
||||||
return Response(item_info(item,request.user))
|
return Response(item_info(item,request.user))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user