From f1f7eed9c64be2f2ee7e4dab2129495a5b338edb Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 18:12:46 +0200 Subject: [PATCH] parfait --- backend/drive/test_granular_permissions.py | 112 ---- backend/drive/test_item_sharing.py | 148 ----- backend/drive/test_private_layout.py | 272 -------- backend/drive/test_protected_deletions.py | 168 ----- .../drive/test_quota_allocation_protection.py | 120 ---- .../test_share_permissions_and_retention.py | 133 ---- .../drive/test_space_reparent_and_access.py | 154 ----- backend/drive/tests.py | 624 ------------------ .../src/components/drive/DriveExplorer.jsx | 8 +- .../src/components/drive/DriveSidebar.jsx | 4 +- frontend/src/hooks/useDriveController.js | 2 +- 11 files changed, 10 insertions(+), 1735 deletions(-) delete mode 100644 backend/drive/test_granular_permissions.py delete mode 100644 backend/drive/test_item_sharing.py delete mode 100644 backend/drive/test_private_layout.py delete mode 100644 backend/drive/test_protected_deletions.py delete mode 100644 backend/drive/test_quota_allocation_protection.py delete mode 100644 backend/drive/test_share_permissions_and_retention.py delete mode 100644 backend/drive/test_space_reparent_and_access.py delete mode 100644 backend/drive/tests.py diff --git a/backend/drive/test_granular_permissions.py b/backend/drive/test_granular_permissions.py deleted file mode 100644 index c632e5e..0000000 --- a/backend/drive/test_granular_permissions.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Run: USE_SQLITE=true python manage.py test drive.test_granular_permissions""" -import tempfile -import uuid -from pathlib import Path -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.core.files.uploadedfile import SimpleUploadedFile -from rest_framework.test import APIClient -from .models import DriveItem, DriveSpace, SpaceGrant, ItemGrant -from .spaces import ensure_space, ensure_private_space -from .quotas import quota_state, occupancy - - -class GranularPermissionTests(TestCase): - def setUp(self): - tmp=tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.dir=Path(tmp.name) - settings=override_settings(DRIVE_STORAGE_ROOT=self.dir, FILE_UPLOAD_TEMP_DIR=str(self.dir/'.uploads')) - settings.enable();self.addCleanup(settings.disable) - User=get_user_model() - self.admin=User.objects.create_superuser('test-admin','admin@localhost','ComplexPassword123!') - self.member=User.objects.create_user('test-member',password='ComplexPassword123!') - self.guest=User.objects.create_user('test-guest',password='ComplexPassword123!') - self.root=ensure_space(self.admin) - self.private=ensure_private_space(self.member) - self.client=APIClient() - self.client.force_authenticate(self.admin) - - def test_delegation_and_independent_rights(self): - rights=['view','download','manage_access'] - SpaceGrant.objects.create(user=self.member,space=self.root,permissions=rights,can_invite=True) - self.client.force_authenticate(self.member) - self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'NO'},format='json').status_code,403) - created=self.client.post('/api/drive/invitations/',{ - 'parent_space':self.root.pk,'permissions':['view','download']},format='json') - self.assertEqual(created.status_code,201,created.data) - escalation=self.client.post('/api/drive/invitations/',{ - 'parent_space':self.root.pk,'permissions':['view','modify']},format='json') - self.assertEqual(escalation.status_code,403) - no_create=self.client.post('/api/drive/spaces/create/',{ - 'parent_space':self.root.pk,'display_name':'Impossible','quota_bytes':None},format='json') - self.assertEqual(no_create.status_code,403) - self.client.force_authenticate(self.admin) - grant=self.client.post('/api/drive/spaces/users/',{ - 'parent_space':self.root.pk,'user_id':self.guest.pk, - 'permissions':['view','add']},format='json',QUERY_STRING=f'parent_space={self.root.pk}') - self.assertEqual(grant.status_code,201,grant.data) - self.client.force_authenticate(self.guest) - self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'OK'},format='json').status_code,201) - self.assertEqual(self.client.post('/api/drive/invitations/',{'parent_space':self.root.pk},format='json').status_code,403) - - def test_granular_folder_share_and_anonymous_preview(self): - folder=DriveItem.objects.create(owner=self.member,space=self.private,name='Photos',is_folder=True) - image=DriveItem.objects.create(owner=self.member,space=self.private,parent=folder,name='photo.png', - storage_key=str(uuid.uuid4()),storage_location=f'{self.private.relative_path}/Photos/photo.png',size=8,mime_type='image/png') - filename=self.dir/image.storage_location - filename.parent.mkdir(parents=True,exist_ok=True) - filename.write_bytes(b'PNGDATA!') - self.client.force_authenticate(self.member) - share=self.client.post(f'/api/drive/items/{folder.pk}/shares/',{ - 'user_id':self.guest.pk,'permissions':['view','download','add']},format='json') - self.assertEqual(share.status_code,201,share.data) - self.client.force_authenticate(self.guest) - self.assertEqual(self.client.post('/api/drive/folders/',{ - 'name':'Nouveau','parent':str(folder.pk),'space':self.private.pk},format='json').status_code,201) - self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/trash/').status_code,403) - self.client.force_authenticate(self.member) - result=self.client.post(f'/api/drive/items/{image.pk}/public-links/',{},format='json') - self.assertEqual(result.status_code,201,result.data) - anonymous=APIClient() - token=result.data['token'] - inline=anonymous.get(f'/api/public/{token}/files/{image.pk}/?inline=1') - self.assertEqual(inline.status_code,200) - self.assertEqual(inline['Content-Type'],'image/png') - self.assertEqual(b''.join(inline.streaming_content),b'PNGDATA!') - partial=anonymous.get(f'/api/public/{token}/files/{image.pk}/?inline=1',HTTP_RANGE='bytes=0-2') - self.assertEqual(partial.status_code,206) - self.assertEqual(b''.join(partial.streaming_content),b'PNG') - self.client.force_authenticate(self.member) - self.assertEqual(self.client.patch(f'/api/drive/items/{folder.pk}/shares/{share.data["id"]}/permissions/',{ - 'permissions':['view']},format='json').status_code,200) - self.client.force_authenticate(self.guest) - self.assertEqual(self.client.post('/api/drive/folders/',{ - 'name':'Interdit','parent':str(folder.pk),'space':self.private.pk},format='json').status_code,403) - self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/link/',{'inline':False},format='json').status_code,403) - self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/link/',{'inline':True},format='json').status_code,200) - - def test_unreserved_child_quota_parent_usage_reduces_child_capacity(self): - gib=1024**3 - self.root.quota_bytes=50*gib - self.root.save(update_fields=['quota_bytes']) - self.client.force_authenticate(self.admin) - res=self.client.post('/api/drive/spaces/create/',{ - 'parent_space':self.root.pk,'display_name':'Sous espace','quota_bytes':30*gib},format='json') - self.assertEqual(res.status_code,201,res.data) - child=DriveSpace.objects.get(pk=res.data['id']) - self.assertEqual(child.parent_id,self.root.pk) - DriveItem.objects.create(space=self.root,owner=self.admin,name='parent.bin',size=20*gib, - storage_key=str(uuid.uuid4()),storage_location='unused') - self.assertEqual(quota_state(self.root)['used_bytes'],20*gib) - self.assertEqual(quota_state(child)['used_bytes'],0) - DriveItem.objects.filter(space=self.root).update(size=30*gib) - self.assertEqual(quota_state(self.root)['used_bytes'],30*gib) - self.assertEqual(quota_state(child)['used_bytes'],10*gib) - self.assertEqual(quota_state(child)['available_bytes'],20*gib) - DriveItem.objects.create(space=child,owner=self.admin,name='child.bin',size=10*gib, - storage_key=str(uuid.uuid4()),storage_location='unused2') - self.assertGreaterEqual(quota_state(self.root)['used_bytes'],10*gib) - self.assertEqual(quota_state(self.root)['used_bytes'],40*gib) - self.assertEqual(quota_state(child)['used_bytes'],20*gib) - self.assertEqual(occupancy(child)[0],10*gib) diff --git a/backend/drive/test_item_sharing.py b/backend/drive/test_item_sharing.py deleted file mode 100644 index d74ae24..0000000 --- a/backend/drive/test_item_sharing.py +++ /dev/null @@ -1,148 +0,0 @@ -"""New invitation and file-sharing semantics. - -Run: USE_SQLITE=true python manage.py test drive.test_item_sharing -""" -import tempfile -import uuid -from pathlib import Path -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIClient -from .models import DriveItem, DriveSpace, SpaceGrant -from .spaces import ensure_space, ensure_private_space - - -class SharesTests(TestCase): - def setUp(self): - tmp=tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.storage=Path(tmp.name) - overrides=override_settings(DRIVE_STORAGE_ROOT=self.storage, - FILE_UPLOAD_TEMP_DIR=str(self.storage/'.uploads')) - overrides.enable() - self.addCleanup(overrides.disable) - User=get_user_model() - self.admin=User.objects.create_superuser('admin-shares','admin@example.org','ComplexPassword123!') - self.root=ensure_space(self.admin) - ensure_private_space(self.admin) - self.client=APIClient() - self.client.force_authenticate(user=self.admin) - - def invite_user(self, name, **rights): - self.client.force_authenticate(user=self.admin) - created=self.client.post('/api/drive/invitations/',{'parent_space':self.root.pk,**rights},format='json') - self.assertEqual(created.status_code,201,created.data) - client=APIClient() - accepted=client.post(f"/api/invitations/{created.data['token']}/accept/", - {'username':name,'password':'ComplexPassword123!','first_name':'Test','last_name':name},format='json') - self.assertEqual(accepted.status_code,201,accepted.data) - return get_user_model().objects.get(username=name) - - def test_new_invitee_uses_existing_space_without_new_shared_directory(self): - alice=self.invite_user('alice-share',can_write_parent=False,can_invite=True) - self.assertFalse(DriveSpace.objects.filter(user=alice).exists()) - self.assertTrue(SpaceGrant.objects.filter(user=alice,space=self.root,can_invite=True,can_write=False).exists()) - self.assertEqual(ensure_space(alice).pk,self.root.pk) - self.assertEqual(ensure_private_space(alice).parent_id,self.root.pk) - self.client.force_authenticate(user=alice) - self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'not-writable'},format='json').status_code,403) - too_much=self.client.post('/api/drive/invitations/',{ - 'parent_space':self.root.pk,'can_view_parent':True,'can_write_parent':True},format='json') - self.assertEqual(too_much.status_code,403) - allowed=self.client.post('/api/drive/invitations/',{ - 'parent_space':self.root.pk,'can_view_parent':True,'can_invite':True},format='json') - self.assertEqual(allowed.status_code,201,allowed.data) - # Creating a subspace is an explicit operation for users with can_invite. - sub=self.client.post('/api/drive/spaces/create/',{ - 'parent_space':self.root.pk,'display_name':'Equipe','quota_bytes':None},format='json') - self.assertEqual(sub.status_code,201,sub.data) - - def test_private_item_share_is_read_only_and_revocable(self): - alice=self.invite_user('alice-files') - bob=self.invite_user('bob-files') - private=ensure_private_space(alice) - folder=DriveItem.objects.create(owner=alice,space=private,name='Documents',is_folder=True) - child=DriveItem.objects.create(owner=alice,space=private,parent=folder,name='photo.jpg', - storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/Documents/photo.jpg',size=3) - secret=DriveItem.objects.create(owner=alice,space=private,name='secret.jpg', - storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/secret.jpg',size=3) - self.client.force_authenticate(user=alice) - shared=self.client.post(f'/api/drive/items/{folder.pk}/shares/',{'user_id':bob.pk},format='json') - self.assertEqual(shared.status_code,201,shared.data) - self.client.force_authenticate(user=bob) - listing=self.client.get('/api/drive/items/',{'view':'shared'}) - self.assertEqual(listing.status_code,200,listing.data) - self.assertEqual([item['name'] for item in listing.data['items']],['Documents']) - children=self.client.get('/api/drive/items/',{'view':'shared','parent':str(folder.pk)}) - self.assertEqual(children.status_code,200,children.data) - self.assertEqual(children.data['items'][0]['id'],str(child.pk)) - self.assertFalse(children.data['items'][0]['can_write']) - self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/link/').status_code,200) - self.assertEqual(self.client.post(f'/api/drive/items/{secret.pk}/link/').status_code,403) - self.assertEqual(self.client.patch(f'/api/drive/items/{child.pk}/',{'name':'change.jpg'},format='json').status_code,403) - self.client.force_authenticate(user=alice) - self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/shares/{shared.data["id"]}/').status_code,204) - self.client.force_authenticate(user=bob) - self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/link/').status_code,403) - - def test_public_link_access_and_revocation(self): - alice=self.invite_user('alice-public') - private=ensure_private_space(alice) - folder=DriveItem.objects.create(owner=alice,space=private,name='Photos',is_folder=True) - image=DriveItem.objects.create(owner=alice,space=private,parent=folder,name='pic.png', - storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/Photos/pic.png',size=3) - path=self.storage/image.storage_location - path.parent.mkdir(parents=True,exist_ok=True) - path.write_bytes(b'PNG') - self.client.force_authenticate(user=alice) - public=self.client.post(f'/api/drive/items/{folder.pk}/public-links/',{},format='json') - self.assertEqual(public.status_code,201,public.data) - token=public.data['token'] - anonymous=APIClient() - res=anonymous.get(f'/api/public/{token}/') - self.assertEqual(res.status_code,200,res.data) - self.assertEqual(res.data['items'][0]['name'],'pic.png') - content=anonymous.get(f'/api/public/{token}/files/{image.pk}/') - self.assertEqual(content.status_code,200) - self.assertEqual(b''.join(content.streaming_content),b'PNG') - links=self.client.get(f'/api/drive/items/{folder.pk}/shares/').data['links'] - self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/public-links/{links[0]["id"]}/').status_code,204) - self.assertEqual(anonymous.get(f'/api/public/{token}/').status_code,404) - - # A directly shared file must download without either a login or JWT. - direct=self.client.post(f'/api/drive/items/{image.pk}/public-links/',{},format='json') - self.assertEqual(direct.status_code,201,direct.data) - direct_token=direct.data['token'] - direct_preview=anonymous.get(f'/api/public/{direct_token}/') - self.assertEqual(direct_preview.status_code,200,direct_preview.data) - self.assertFalse(direct_preview.data['root']['is_folder']) - direct_file=anonymous.get(f'/api/public/{direct_token}/files/{image.pk}/') - self.assertEqual(direct_file.status_code,200) - self.assertEqual(b''.join(direct_file.streaming_content),b'PNG') - - def test_admin_user_list_is_private_to_admin(self): - alice=self.invite_user('alice-admin-list') - self.client.force_authenticate(user=alice) - self.assertEqual(self.client.get('/api/drive/admin/users/').status_code,403) - self.client.force_authenticate(user=self.admin) - users=self.client.get('/api/drive/admin/users/',{'q':'alice-admin-list'}) - self.assertEqual(users.status_code,200,users.data) - self.assertEqual(users.data['users'][0]['space_name'],self.root.display_name) - - def test_members_never_lists_viewer_as_direct_added_user(self): - """Hide redundant grants for the viewer or space owner, not real guests.""" - User = get_user_model() - delegate = User.objects.create_user('delegate', password='ComplexPassword123!') - guest = User.objects.create_user('real-guest', password='ComplexPassword123!') - SpaceGrant.objects.create(space=self.root, user=self.admin, can_invite=True) - SpaceGrant.objects.create(space=self.root, user=delegate, can_invite=True) - SpaceGrant.objects.create(space=self.root, user=guest) - - response = self.client.get('/api/drive/spaces/members/', {'parent_space': self.root.pk}) - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual({g['user_id'] for g in response.data['grants']}, {delegate.pk, guest.pk}) - - self.client.force_authenticate(user=delegate) - response = self.client.get('/api/drive/spaces/members/', {'parent_space': self.root.pk}) - self.assertEqual(response.status_code, 200, response.data) - self.assertEqual([g['user_id'] for g in response.data['grants']], [guest.pk]) diff --git a/backend/drive/test_private_layout.py b/backend/drive/test_private_layout.py deleted file mode 100644 index 6925995..0000000 --- a/backend/drive/test_private_layout.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Private disk layout, 50-Go private cap, and real-byte pooled accounting. - -Run with: USE_SQLITE=true python manage.py test drive.test_private_layout -""" -import tempfile -import uuid -from pathlib import Path - -from django.contrib.auth import get_user_model -from django.core.files.uploadedfile import SimpleUploadedFile -from django.core.management import call_command -from django.core.management.base import CommandError -from django.db import transaction -from django.test import TestCase, override_settings -from rest_framework.test import APIClient - -from .models import DriveItem, DriveSpace, SpaceInvitation, SpaceGrant -from .quotas import (PRIVATE_SPACE_QUOTA_BYTES, QuotaExceeded, assert_space_capacity, - assert_updated_quota, locked_quota_tree, occupancy, quota_state) -from .spaces import ensure_private_space, ensure_space, create_space, storage_path - - -class PrivateStorageTests(TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.rootdir = Path(self.temp.name) - overrides = override_settings( - DRIVE_STORAGE_ROOT=self.rootdir, - FILE_UPLOAD_TEMP_DIR=str(self.rootdir / '.uploads'), - ) - overrides.enable() - self.addCleanup(overrides.disable) - User = get_user_model() - self.admin = User.objects.create_superuser('root-admin', 'admin@example.org', 'TestPassword123!') - self.shared = ensure_space(self.admin) - self.private = ensure_private_space(self.admin) - self.client = APIClient() - self.client.force_authenticate(user=self.admin) - - def test_private_directories_are_separate_from_shared_spaces(self): - self.assertEqual(self.private.parent_id, self.shared.pk) - self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') - self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) - self.assertTrue((self.rootdir / self.private.relative_path).is_dir()) - person = get_user_model().objects.create_user('member', password='TestPassword123!') - shared = create_space(person, self.shared, 'Compte membre') - private = ensure_private_space(person) - self.assertTrue(shared.relative_path.startswith(f'{shared.directory_name}')) - self.assertEqual(private.relative_path, f'privates/{private.directory_name}') - self.assertEqual(private.parent_id, shared.pk) - self.assertNotEqual(shared.directory_name, private.directory_name) - - def test_private_usage_counts_toward_assigned_space_without_reserving(self): - self.shared.quota_bytes = 100 - self.shared.save(update_fields=['quota_bytes']) - # Private can be 50 Go even when the assigned budget is just 100 bytes. - self.assertEqual(quota_state(self.shared)['available_bytes'], 100) - self.assertEqual(quota_state(self.private)['available_bytes'], 100) - created = self.client.post('/api/drive/uploads/', { - 'files': [SimpleUploadedFile('photo.jpg', b'abcdef')] - }, format='multipart') - self.assertEqual(created.status_code, 201, created.data) - node = DriveItem.objects.get(pk=created.data['items'][0]['id']) - self.assertEqual(node.storage_location, f'{self.private.relative_path}/photo.jpg') - self.assertEqual(storage_path(node).read_bytes(), b'abcdef') - self.assertEqual(occupancy(self.shared)[0], 6) - self.assertEqual(quota_state(self.shared)['available_bytes'], 94) - self.assertEqual(quota_state(self.private)['available_bytes'], 94) - self.assertEqual(quota_state(self.private)['quota_bytes'], PRIVATE_SPACE_QUOTA_BYTES) - self.assertEqual(quota_state(self.private)['used_bytes'], 6) - rejected = self.client.patch(f'/api/drive/spaces/{self.private.pk}/quota/', { - 'quota_bytes': None - }, format='json') - self.assertEqual(rejected.status_code, 403) - - def test_actual_private_use_is_pooled_in_100_go_assigned_space(self): - person = get_user_model().objects.create_user('quota-user', password='TestPassword123!') - assigned = create_space(person, self.shared, '100 Go', quota_bytes=100 * 1024 ** 3) - private = ensure_private_space(person) - gib = 1024 ** 3 - self.assertEqual(private.parent_id, assigned.pk) - self.assertEqual(private.relative_path, f'privates/{private.directory_name}') - self.assertEqual(quota_state(assigned)['available_bytes'], 100 * gib) - self.assertEqual(quota_state(private)['available_bytes'], 50 * gib) - # Existing 20 Go in shared space. This leaves both limits at their max. - DriveItem.objects.create( - owner=person, space=assigned, name='shared.bin', - storage_key=str(uuid.uuid4()), storage_location=f'{assigned.relative_path}/shared.bin', - size=20 * gib, - ) - self.assertEqual(quota_state(assigned)['used_bytes'], 20 * gib) - self.assertEqual(quota_state(private)['used_bytes'], 0) - DriveItem.objects.create( - owner=person, space=private, name='private.bin', - storage_key=str(uuid.uuid4()), storage_location=f'{private.relative_path}/private.bin', - size=20 * gib, - ) - self.assertEqual(occupancy(assigned)[0], 40 * gib) - self.assertEqual(quota_state(assigned)['used_bytes'], 40 * gib) - self.assertEqual(quota_state(assigned)['available_bytes'], 60 * gib) - self.assertEqual(quota_state(private)['used_bytes'], 20 * gib) - self.assertEqual(quota_state(private)['available_bytes'], 30 * gib) - with transaction.atomic(): - with locked_quota_tree(assigned): - assert_space_capacity(assigned, 60 * gib) - with self.assertRaises(QuotaExceeded): - assert_space_capacity(assigned, 60 * gib + 1) - assert_updated_quota(assigned, 40 * gib) - with self.assertRaisesRegex(ValueError, 'fichiers'): - assert_updated_quota(assigned, 39 * gib) - - def test_parent_limit_blocks_private_even_before_50_go(self): - self.shared.quota_bytes = 20 - self.shared.save(update_fields=['quota_bytes']) - DriveItem.objects.create( - owner=self.admin, space=self.private, name='existing.bin', - storage_key=str(uuid.uuid4()), storage_location=f'{self.private.relative_path}/existing.bin', size=19, - ) - self.assertEqual(quota_state(self.private)['available_bytes'], 1) - with transaction.atomic(): - with locked_quota_tree(self.private): - assert_space_capacity(self.private, 1) - with self.assertRaises(QuotaExceeded): - assert_space_capacity(self.private, 2) - - def test_invitation_preview_includes_creator_full_name_and_parent_space(self): - self.admin.first_name = 'Camille' - self.admin.last_name = 'Martin' - self.admin.save(update_fields=['first_name', 'last_name']) - result = self.client.post('/api/drive/invitations/', { - 'parent_space': self.shared.pk, - }, format='json') - self.assertEqual(result.status_code, 201, result.data) - preview = APIClient().get(f"/api/invitations/{result.data['token']}/") - self.assertEqual(preview.status_code, 200, preview.data) - self.assertEqual(preview.data['inviter_name'], 'Camille Martin') - self.assertEqual(preview.data['parent_name'], self.shared.display_name) - - self.admin.first_name = '' - self.admin.last_name = '' - self.admin.save(update_fields=['first_name', 'last_name']) - preview = APIClient().get(f"/api/invitations/{result.data['token']}/") - self.assertEqual(preview.status_code, 200, preview.data) - self.assertEqual(preview.data['inviter_name'], self.admin.username) - - def test_invitation_has_no_quota_even_from_an_old_client(self): - self.shared.quota_bytes = 15 - self.shared.save(update_fields=['quota_bytes']) - # Even an old client cannot allocate storage through an invitation. - result = self.client.post('/api/drive/invitations/', { - 'parent_space': self.shared.pk, 'quota_bytes': 10, - }, format='json') - self.assertEqual(result.status_code, 201, result.data) - self.assertNotIn('quota_bytes', result.data) - invitation = SpaceInvitation.objects.get(pk=result.data['id']) - self.assertIsNone(invitation.quota_bytes) - self.assertEqual(quota_state(self.shared)['available_bytes'], 15) - - # Legacy unredeemed invitations may still contain a saved quota. - # The quota must not be applied when the invite is accepted. - invitation.quota_bytes = 1 - invitation.save(update_fields=['quota_bytes']) - preview = APIClient().get(f"/api/invitations/{result.data['token']}/") - self.assertEqual(preview.status_code, 200, preview.data) - self.assertNotIn('quota_bytes', preview.data) - accepted = APIClient().post( - f"/api/invitations/{result.data['token']}/accept/", { - 'username': 'invitee', 'password': 'TestPassword123!', - 'first_name': 'Jean', 'last_name': 'Dupont', - 'display_name': 'Dossier invité', - }, format='json', - ) - self.assertEqual(accepted.status_code, 201, accepted.data) - user = get_user_model().objects.get(username='invitee') - self.assertFalse(DriveSpace.objects.filter(user=user).exists()) - self.assertTrue(SpaceGrant.objects.filter(user=user, space=self.shared).exists()) - assigned = ensure_space(user) - private = ensure_private_space(user) - self.assertEqual(assigned.pk, self.shared.pk) - self.assertEqual(private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) - self.assertEqual(private.parent_id, self.shared.pk) - self.assertEqual(quota_state(assigned)['available_bytes'], 15) - self.assertEqual(quota_state(private)['available_bytes'], 15) - - def test_private_pending_uploads_count_in_parent(self): - from .models import UploadSession - UploadSession.objects.create( - owner=self.admin, space=self.private, name='movie.bin', total_size=1234 - ) - self.assertEqual(quota_state(self.shared)['reserved_bytes'], 1234) - self.assertEqual(quota_state(self.shared)['used_bytes'], 0) - self.assertEqual(quota_state(self.private)['available_bytes'], PRIVATE_SPACE_QUOTA_BYTES - 1234) - - def test_private_50_go_upload_limit_is_enforced(self): - DriveItem.objects.create( - owner=self.admin, space=self.private, name='large.bin', - storage_key=str(uuid.uuid4()), - storage_location=f'{self.private.relative_path}/large.bin', - size=PRIVATE_SPACE_QUOTA_BYTES - 1, - ) - with transaction.atomic(): - with locked_quota_tree(self.private): - with self.assertRaises(QuotaExceeded): - assert_space_capacity(self.private, 2) - assert_space_capacity(self.private, 1) - - def simulate_legacy_private(self): - """Relocate the empty modern directory to the historical nested location.""" - new = self.rootdir / self.private.relative_path - new.rmdir() - old_name = self.private.directory_name - old = f'{old_name}' - src = self.rootdir / old - (src / 'Documents').mkdir(parents=True) - (src / 'Documents' / 'facture.pdf').write_bytes(b'PDF CONTENT') - self.private.parent = self.shared - self.private.relative_path = old - self.private.quota_bytes = None - self.private.save(update_fields=['parent', 'relative_path', 'quota_bytes']) - folder = DriveItem.objects.create( - owner=self.admin, space=self.private, name='Documents', is_folder=True, - ) - item = DriveItem.objects.create( - owner=self.admin, space=self.private, parent=folder, name='facture.pdf', - storage_key=str(uuid.uuid4()), storage_location=f'{old}/Documents/facture.pdf', - size=11, - ) - return old, item - - def test_migrate_private_storage_dry_run_and_apply(self): - old, item = self.simulate_legacy_private() - call_command('move_private_storage') - self.private.refresh_from_db() - self.assertEqual(self.private.relative_path, old) - self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists()) - call_command('move_private_storage', '--apply') - self.private.refresh_from_db() - item.refresh_from_db() - self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') - self.assertEqual(self.private.parent_id, self.shared.pk) - self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) - self.assertEqual(item.storage_location, f'{self.private.relative_path}/Documents/facture.pdf') - self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT') - self.assertFalse((self.rootdir / old).exists()) - self.assertFalse((self.rootdir / '.gooddrive-move-privates.json').exists()) - call_command('move_private_storage', '--apply') # Idempotent. - - def test_interrupted_migration_can_resume_after_directory_move(self): - from .management.commands.move_private_storage import Command, write_journal - old, item = self.simulate_legacy_private() - plan = Command().plan() - journal = self.rootdir / '.gooddrive-move-privates.json' - write_journal(journal, plan) - new = self.rootdir / 'privates' / self.private.directory_name - (self.rootdir / old).rename(new) # Simulate a crash before SQL update. - call_command('move_private_storage', '--apply') - item.refresh_from_db() - self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT') - self.assertFalse(journal.exists()) - - def test_migration_never_overwrites_existing_destination(self): - old, item = self.simulate_legacy_private() - target = self.rootdir / 'privates' / self.private.directory_name - target.mkdir(parents=True) - (target / 'unrelated.txt').write_text('KEEP ME') - with self.assertRaises(CommandError): - call_command('move_private_storage', '--apply') - self.assertEqual((target / 'unrelated.txt').read_text(), 'KEEP ME') - self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists()) - item.refresh_from_db() - self.assertEqual(item.storage_location, f'{old}/Documents/facture.pdf') diff --git a/backend/drive/test_protected_deletions.py b/backend/drive/test_protected_deletions.py deleted file mode 100644 index 052a4a2..0000000 --- a/backend/drive/test_protected_deletions.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Deletion contract: admin may remove shared spaces, never private spaces. - -Run: USE_SQLITE=true python manage.py test drive.test_protected_deletions -""" -import tempfile -import uuid -from pathlib import Path -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIClient -from .models import DriveItem, DriveSpace, SpaceGrant, ArchivedDriveAccount -from .spaces import ensure_space, ensure_private_space, create_space - - -class ProtectedDeletionTests(TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.storage = Path(tmp.name) - override = override_settings(DRIVE_STORAGE_ROOT=self.storage, - FILE_UPLOAD_TEMP_DIR=str(self.storage / '.uploads')) - override.enable() - self.addCleanup(override.disable) - User = get_user_model() - self.admin = User.objects.create_superuser('test-admin', 'admin@example.org', 'VeryStrongPass123!') - self.alice = User.objects.create_user('test-alice', password='VeryStrongPass123!') - self.root = ensure_space(self.admin) - self.admin_private = ensure_private_space(self.admin) - self.assigned = create_space(self.alice, self.root, 'Alice partagé') - self.private = ensure_private_space(self.alice) - self.client = APIClient() - self.client.force_authenticate(user=self.admin) - - def private_file(self): - item = DriveItem.objects.create(owner=self.alice, space=self.private, - name='mon-secret.txt', storage_key=str(uuid.uuid4()), - storage_location=f'{self.private.relative_path}/mon-secret.txt', size=6) - path = self.storage / item.storage_location - path.write_bytes(b'secret') - return item, path - - def test_admin_cannot_browse_or_delete_any_other_private_space(self): - item, path = self.private_file() - result = self.client.get('/api/drive/spaces/') - self.assertEqual(result.status_code, 200, result.data) - self.assertNotIn(self.private.pk, [s['id'] for s in result.data['spaces']]) - self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code, 403) - self.assertEqual(self.client.get('/api/drive/stats/', {'space':self.private.pk}).status_code, 403) - self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/link/').status_code, 403) - self.assertEqual(self.client.delete(f'/api/drive/spaces/{self.private.pk}/', - {'confirm_name':self.private.display_name}, format='json').status_code, 403) - self.client.force_authenticate(user=self.alice) - self.assertEqual(self.client.delete(f'/api/drive/spaces/{self.private.pk}/', - {'confirm_name':self.private.display_name}, format='json').status_code, 403) - self.assertTrue(path.is_file()) - - def test_admin_can_delete_assigned_shared_space_without_private_loss(self): - secret, path = self.private_file() - shared_path = self.storage / self.assigned.relative_path - self.assertTrue(shared_path.is_dir()) - with self.captureOnCommitCallbacks(execute=True): - res = self.client.delete(f'/api/drive/spaces/{self.assigned.pk}/', - {'confirm_name':self.assigned.display_name}, format='json') - self.assertEqual(res.status_code, 200, res.data) - self.assertFalse(DriveSpace.objects.filter(pk=self.assigned.pk).exists()) - self.assertFalse(shared_path.exists()) - self.private.refresh_from_db() - self.assertEqual(self.private.parent_id, self.root.pk) - self.assertTrue(path.exists()) - self.assertTrue(DriveItem.objects.filter(pk=secret.pk).exists()) - self.client.force_authenticate(user=self.alice) - self.assertIsNone(ensure_space(self.alice)) - self.assertIsNone(self.client.get('/api/auth/me/').data['space_id']) - self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code, 200) - self.assertEqual(self.client.get('/api/drive/items/', {'space':self.root.pk}).status_code, 403) - - def test_admin_root_reset_keeps_root_and_private_files(self): - item, private_path = self.private_file() - root_folder = DriveItem.objects.create(owner=self.admin, space=self.root, - name='public-folder', is_folder=True) - public_path = self.storage / 'public-folder' - public_path.mkdir() - shared_path = self.storage / self.assigned.relative_path - self.assertTrue(shared_path.is_dir()) - with self.captureOnCommitCallbacks(execute=True): - res = self.client.delete(f'/api/drive/spaces/{self.root.pk}/', - {'confirm_name':self.root.display_name}, format='json') - self.assertEqual(res.status_code, 200, res.data) - self.assertTrue(res.data['root_reset']) - self.assertTrue(DriveSpace.objects.filter(pk=self.root.pk).exists()) - self.assertFalse(DriveSpace.objects.filter(pk=self.assigned.pk).exists()) - self.assertFalse(DriveItem.objects.filter(pk=root_folder.pk).exists()) - self.assertFalse(shared_path.exists()) - self.assertFalse(public_path.exists()) - self.assertTrue(private_path.exists()) - self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) - self.assertTrue((self.storage/'privates').is_dir()) - - def test_user_deletion_removes_private_directory_and_keeps_shared_files(self): - item, path = self.private_file() - self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', - {'confirm_username':self.admin.username}, format='json').status_code, 403) - self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', - {'confirm_username':'wrong'}, format='json').status_code, 400) - shared = DriveItem.objects.create(owner=self.alice, space=self.root, - name='communal.txt', storage_key=str(uuid.uuid4()), - storage_location='communal.txt', size=4) - shared_path = self.storage / 'communal.txt' - shared_path.write_bytes(b'team') - with self.captureOnCommitCallbacks(execute=True): - self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', - {'confirm_username':self.alice.username}, format='json').status_code, 204) - self.alice.refresh_from_db() - self.assertFalse(self.alice.is_active) - self.assertFalse(self.alice.has_usable_password()) - self.assertTrue(ArchivedDriveAccount.objects.filter(user=self.alice).exists()) - self.assertNotIn(self.alice.pk, [u['id'] for u in self.client.get('/api/drive/admin/users/').data['users']]) - self.assertFalse(path.exists()) - self.assertFalse((self.storage / self.private.relative_path).exists()) - self.assertFalse(DriveSpace.objects.filter(pk=self.private.pk).exists()) - self.assertFalse(DriveItem.objects.filter(pk=item.pk).exists()) - self.assertTrue(shared_path.exists()) - self.assertTrue(DriveItem.objects.filter(pk=shared.pk).exists()) - self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', - {'confirm_username':self.alice.username}, format='json').status_code, 404) - - def test_non_admin_cannot_delete_other_users(self): - self.client.force_authenticate(user=self.alice) - self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', - {'confirm_username':self.admin.username}, format='json').status_code, 403) - - def test_admin_can_revoke_space_grants_without_removing_private_storage(self): - from .models import SpaceGrant - # Access granted explicitly by invitation/existing-account grant. - grant = SpaceGrant.objects.create(user=self.alice, space=self.root, - can_write=True, can_invite=True) - rsp = self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/') - self.assertEqual(rsp.status_code, 200, rsp.data) - self.assertTrue(any(a['kind'] == 'grant' and a['space_id'] == self.root.pk - for a in rsp.data['accesses'])) - self.assertNotIn(self.private.pk, [a['space_id'] for a in rsp.data['accesses']]) - rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/grant/{self.root.pk}/') - self.assertEqual(rsp.status_code, 204) - self.assertFalse(SpaceGrant.objects.filter(pk=grant.pk).exists()) - self.assertTrue(DriveSpace.objects.filter(pk=self.root.pk).exists()) - self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists()) - - # Older accounts are linked directly to their assigned shared space. - rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/owner/{self.assigned.pk}/') - self.assertEqual(rsp.status_code, 204) - self.assigned.refresh_from_db() - self.assertIsNone(self.assigned.user_id) - self.assertIsNone(ensure_space(self.alice)) - self.client.force_authenticate(user=self.alice) - self.assertEqual(self.client.get('/api/drive/items/', {'space':self.root.pk}).status_code,403) - self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code,200) - self.assertEqual(self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/').status_code,403) - - def test_account_delete_rejects_private_folder_outside_privates(self): - self.private.relative_path = f'{self.assigned.relative_path}/legacy' - self.private.save(update_fields=['relative_path']) - original = self.storage / self.private.relative_path - original.mkdir(parents=True, exist_ok=True) - rsp=self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', - {'confirm_username':self.alice.username},format='json') - self.assertEqual(rsp.status_code,409) - self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists()) - self.assertTrue(original.exists()) diff --git a/backend/drive/test_quota_allocation_protection.py b/backend/drive/test_quota_allocation_protection.py deleted file mode 100644 index 08079ca..0000000 --- a/backend/drive/test_quota_allocation_protection.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Regressions: an invitee cannot enlarge their assigned storage allocation. - -Run with: USE_SQLITE=true python manage.py test drive.test_quota_allocation_protection -""" -import tempfile -from pathlib import Path - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIClient - -from .models import SpaceGrant -from .spaces import _make_numbered_space, ensure_space, space_info - - -class AllocatedQuotaProtectionTests(TestCase): - def setUp(self): - directory = tempfile.TemporaryDirectory() - self.addCleanup(directory.cleanup) - settings = override_settings(DRIVE_STORAGE_ROOT=Path(directory.name), - FILE_UPLOAD_TEMP_DIR=str(Path(directory.name) / '.uploads')) - settings.enable() - self.addCleanup(settings.disable) - - User = get_user_model() - self.admin = User.objects.create_superuser('quota-admin', 'admin@example.com', 'TestPassword123!') - self.member = User.objects.create_user('quota-member', password='TestPassword123!') - self.client = APIClient() - self.root = ensure_space(self.admin) - self.root.quota_bytes = 100 * 1024**3 - self.root.save(update_fields=['quota_bytes']) - self.allocated = _make_numbered_space( - self.root, managed_by=self.admin, display_name='Espace attribué', quota_bytes=50 * 1024**3) - SpaceGrant.objects.create( - user=self.member, space=self.allocated, - permissions=['view', 'download', 'manage_space', 'manage_access', 'create_spaces']) - self.child_by_admin = _make_numbered_space( - self.allocated, managed_by=self.admin, display_name='Sous-espace administrateur', - quota_bytes=20 * 1024**3) - self.child_by_member = _make_numbered_space( - self.allocated, managed_by=self.member, display_name='Sous-espace personnel', - quota_bytes=20 * 1024**3) - self.client.force_authenticate(self.member) - - def test_member_cannot_change_allocation_through_quota_endpoint(self): - response = self.client.patch( - f'/api/drive/spaces/{self.allocated.pk}/quota/', - {'quota_bytes': 80 * 1024**3}, format='json') - self.assertEqual(response.status_code, 403, response.data) - self.allocated.refresh_from_db() - self.assertEqual(self.allocated.quota_bytes, 50 * 1024**3) - - def test_member_can_rename_allocated_space_but_not_change_quota(self): - response = self.client.patch( - f'/api/drive/spaces/{self.allocated.pk}/', - {'display_name': 'Nom autorisé'}, format='json') - self.assertEqual(response.status_code, 200, response.data) - forbidden = self.client.patch( - f'/api/drive/spaces/{self.allocated.pk}/', - {'display_name': 'Nom non enregistré', 'quota_bytes': None}, format='json') - self.assertEqual(forbidden.status_code, 403, forbidden.data) - self.allocated.refresh_from_db() - self.assertEqual(self.allocated.display_name, 'Nom autorisé') - self.assertEqual(self.allocated.quota_bytes, 50 * 1024**3) - self.assertFalse(space_info(self.allocated, self.member)['can_edit_quota']) - - def test_member_cannot_change_other_users_allocations_using_manage_access(self): - response = self.client.patch( - f'/api/drive/spaces/members/{self.child_by_admin.pk}/', - {'quota_bytes': 40 * 1024**3}, format='json') - self.assertEqual(response.status_code, 403, response.data) - self.child_by_admin.refresh_from_db() - self.assertEqual(self.child_by_admin.quota_bytes, 20 * 1024**3) - - def test_member_can_adjust_the_subspace_they_created(self): - self.assertTrue(space_info(self.child_by_member, self.member)['can_edit_quota']) - response = self.client.patch( - f'/api/drive/spaces/{self.child_by_member.pk}/', - {'quota_bytes': 30 * 1024**3}, format='json') - self.assertEqual(response.status_code, 200, response.data) - self.child_by_member.refresh_from_db() - self.assertEqual(self.child_by_member.quota_bytes, 30 * 1024**3) - - def test_admin_still_controls_allocated_quota(self): - self.client.force_authenticate(self.admin) - response = self.client.patch( - f'/api/drive/spaces/{self.allocated.pk}/quota/', - {'quota_bytes': 75 * 1024**3}, format='json') - self.assertEqual(response.status_code, 200, response.data) - self.allocated.refresh_from_db() - self.assertEqual(self.allocated.quota_bytes, 75 * 1024**3) - self.assertTrue(space_info(self.allocated, self.admin)['can_edit_quota']) - - def test_legacy_assigned_owner_must_not_enlarge_allocation(self): - User = get_user_model() - legacy = User.objects.create_user('quota-legacy', password='TestPassword123!') - allocated = _make_numbered_space( - self.root, user=legacy, display_name='Ancien espace attribué', - quota_bytes=40 * 1024**3) - self.client.force_authenticate(legacy) - response = self.client.patch( - f'/api/drive/spaces/{allocated.pk}/', - {'quota_bytes': 70 * 1024**3}, format='json') - self.assertEqual(response.status_code, 403, response.data) - allocated.refresh_from_db() - self.assertEqual(allocated.quota_bytes, 40 * 1024**3) - - def test_grant_makes_a_created_space_an_assigned_space_not_resizable(self): - # Even a creator loses quota control if this space has become their - # assigned access point through an explicit share grant. - SpaceGrant.objects.create( - user=self.member, space=self.child_by_member, - permissions=['view', 'download', 'manage_space']) - self.assertFalse(space_info(self.child_by_member, self.member)['can_edit_quota']) - response = self.client.patch( - f'/api/drive/spaces/{self.child_by_member.pk}/', - {'quota_bytes': 45 * 1024**3}, format='json') - self.assertEqual(response.status_code, 403, response.data) - self.child_by_member.refresh_from_db() - self.assertEqual(self.child_by_member.quota_bytes, 20 * 1024**3) diff --git a/backend/drive/test_share_permissions_and_retention.py b/backend/drive/test_share_permissions_and_retention.py deleted file mode 100644 index 2bf1454..0000000 --- a/backend/drive/test_share_permissions_and_retention.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests: USE_SQLITE=true python manage.py test drive.test_share_permissions_and_retention""" -import tempfile -from datetime import timedelta -from pathlib import Path -from django.contrib.auth import get_user_model -from django.core.management import call_command -from django.test import TestCase, override_settings -from django.utils import timezone -from rest_framework.test import APIClient -from .models import DriveItem, DriveSpace, ItemGrant, UploadSession -from .permissions import item_rights -from .spaces import ensure_space, ensure_private_space - - -class ShareAndTrashTests(TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.root = Path(tmp.name) - cfg = override_settings(DRIVE_STORAGE_ROOT=self.root, - FILE_UPLOAD_TEMP_DIR=str(self.root/'.uploads')) - cfg.enable() - self.addCleanup(cfg.disable) - User = get_user_model() - self.owner = User.objects.create_user('share-owner',password='abc1234567890') - self.recipient = User.objects.create_user('share-recipient',password='abc1234567890') - self.space = DriveSpace.objects.create( - user=self.owner,relative_path='',display_name='Partagé') - self.private = ensure_private_space(self.owner) - self.client = APIClient() - self.client.force_authenticate(user=self.owner) - - def make_item(self, name, folder=False, parent=None, space=None): - space = space or self.private - location = '/'.join(filter(None,(space.relative_path, parent.name if parent else '', name))) - entry = DriveItem.objects.create(owner=self.owner, space=space, parent=parent, - name=name,is_folder=folder, storage_key='' if folder else 'f'*36, - storage_location='' if folder else location,size=0 if folder else 4) - path = self.root / location - path.parent.mkdir(parents=True, exist_ok=True) - if folder:path.mkdir(exist_ok=True) - else:path.write_bytes(b'data') - return entry, path - - def share(self, item, permissions): - return self.client.post(f'/api/drive/items/{item.pk}/shares/', - {'user_id':self.recipient.pk,'permissions':permissions},format='json') - - def test_file_shares_only_allow_view_download_modify(self): - file, _ = self.make_item('photo.jpg') - for right in ('add','delete','share','manage_access','create_spaces','manage_space'): - result = self.share(file,['view',right]) - self.assertEqual(result.status_code,403,(right,result.data)) - success = self.share(file,['view','modify']) - self.assertEqual(success.status_code,201,success.data) - self.client.force_authenticate(user=self.recipient) - self.assertIn('modify',item_rights(self.recipient,file)) - self.assertNotIn('delete',item_rights(self.recipient,file)) - self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/trash/').status_code,403) - response=self.client.patch(f'/api/drive/items/{file.pk}/',{'name':'new.jpg'},format='json') - self.assertEqual(response.status_code,200,response.data) - self.assertEqual(self.client.patch(f'/api/drive/items/{file.pk}/',{'parent':None},format='json').status_code,200) - self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/public-links/').status_code,403) - - def test_folder_add_inherited_but_no_delete_or_sharing(self): - folder, _ = self.make_item('docs',folder=True) - child, _ = self.make_item('a.txt',parent=folder) - response=self.share(folder,['view','download','modify','add']) - self.assertEqual(response.status_code,201,response.data) - self.client.force_authenticate(user=self.recipient) - self.assertIn('add',item_rights(self.recipient,folder)) - self.assertIn('modify',item_rights(self.recipient,child)) - self.assertNotIn('share',item_rights(self.recipient,child)) - self.assertEqual(self.client.post(f'/api/drive/folders/',{ - 'parent':str(folder.pk),'space':self.private.pk,'name':'new'},format='json').status_code,201) - self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/trash/').status_code,403) - self.assertEqual(self.client.patch(f'/api/drive/items/{folder.pk}/',{'parent':None},format='json').status_code,200) - - def test_old_unsafe_share_rights_are_clamped_and_removing_share_preserves_file(self): - file,path=self.make_item('old.txt') - grant=ItemGrant.objects.create(item=file,user=self.recipient,created_by=self.owner, - permissions=['view','download','add','delete','share','manage_access','manage_space']) - self.assertEqual(item_rights(self.recipient,file),{'view','download'}) - self.client.force_authenticate(user=self.recipient) - shared=self.client.get('/api/drive/items/',{'view':'shared'}) - self.assertEqual(shared.status_code,200,shared.data) - self.assertEqual(shared.data['items'][0]['share_root_id'],str(file.pk)) - self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/trash/').status_code,403) - gone=self.client.delete(f'/api/drive/items/{file.pk}/my-share/') - self.assertEqual(gone.status_code,204) - self.assertFalse(ItemGrant.objects.filter(pk=grant.pk).exists()) - self.assertTrue(DriveItem.objects.filter(pk=file.pk).exists()) - self.assertEqual(path.read_bytes(),b'data') - - def test_old_unsafe_folder_grant_does_not_allow_space_administration(self): - folder,_=self.make_item('shared',folder=True) - child,_=self.make_item('x.txt',parent=folder) - ItemGrant.objects.create(item=folder,user=self.recipient,created_by=self.owner, - permissions=['view','download','add','delete','modify','share','manage_access','create_spaces','manage_space']) - self.assertEqual(item_rights(self.recipient,child),{'view','download','modify','add'}) - self.client.force_authenticate(user=self.recipient) - self.assertEqual(self.client.delete(f'/api/drive/items/{child.pk}/my-share/').status_code,204) - self.assertTrue(DriveItem.objects.filter(pk=child.pk).exists()) - - def test_cleanup_after_five_days_with_recent_item_preserved(self): - stale,path_stale=self.make_item('old.bin') - recent,path_recent=self.make_item('recent.bin') - DriveItem.objects.filter(pk=stale.pk).update(trashed_at=timezone.now()-timedelta(days=6)) - DriveItem.objects.filter(pk=recent.pk).update(trashed_at=timezone.now()-timedelta(days=4)) - # TestCase wraps callbacks in a transaction. Flush on_commit manually. - with self.captureOnCommitCallbacks(execute=True): - call_command('cleanup_trash',days=5) - self.assertFalse(DriveItem.objects.filter(pk=stale.pk).exists()) - self.assertFalse(path_stale.exists()) - self.assertTrue(DriveItem.objects.filter(pk=recent.pk).exists()) - self.assertTrue(path_recent.exists()) - - def test_cleanup_dry_run_and_active_upload_delay(self): - folder,path=self.make_item('pending',folder=True) - file,_=self.make_item('part.txt',parent=folder) - DriveItem.objects.filter(pk=folder.pk).update(trashed_at=timezone.now()-timedelta(days=7)) - call_command('cleanup_trash',days=5,dry_run=True) - self.assertTrue(DriveItem.objects.filter(pk=folder.pk).exists()) - session=UploadSession.objects.create(owner=self.owner,space=self.private,parent=folder, - name='upload.txt',total_size=5) - call_command('cleanup_trash',days=5) - self.assertTrue(DriveItem.objects.filter(pk=folder.pk).exists()) - session.delete() - with self.captureOnCommitCallbacks(execute=True): - call_command('cleanup_trash',days=5) - self.assertFalse(DriveItem.objects.filter(pk=folder.pk).exists()) - self.assertFalse(DriveItem.objects.filter(pk=file.pk).exists()) - self.assertFalse(path.exists()) diff --git a/backend/drive/test_space_reparent_and_access.py b/backend/drive/test_space_reparent_and_access.py deleted file mode 100644 index c5cda31..0000000 --- a/backend/drive/test_space_reparent_and_access.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Run: USE_SQLITE=true python manage.py test drive.test_space_reparent_and_access""" -import tempfile -import uuid -from pathlib import Path - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIClient - -from .models import DriveItem, DriveSpace, SpaceGrant -from .permissions import space_rights -from .spaces import _make_numbered_space, ensure_space, ensure_private_space -from .quotas import occupancy - - -class SpaceReparentingTests(TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.storage = Path(tmp.name) - override = override_settings(DRIVE_STORAGE_ROOT=self.storage, - FILE_UPLOAD_TEMP_DIR=str(self.storage / '.uploads')) - override.enable() - self.addCleanup(override.disable) - User = get_user_model() - self.admin = User.objects.create_superuser('space-admin', 'admin@example.com', 'password-for-tests') - self.member = User.objects.create_user('space-member', password='password-for-tests') - self.client = APIClient() - self.client.force_authenticate(self.admin) - self.root = ensure_space(self.admin) - gb = 1024 ** 3 - self.root.quota_bytes = 100 * gb - self.root.save(update_fields=['quota_bytes']) - self.a = _make_numbered_space(self.root, managed_by=self.admin, - display_name='A', quota_bytes=60 * gb) - self.b = _make_numbered_space(self.root, managed_by=self.admin, - display_name='B', quota_bytes=50 * gb) - self.child = _make_numbered_space(self.a, managed_by=self.admin, - display_name='Child', quota_bytes=30 * gb) - self.nested = _make_numbered_space(self.child, managed_by=self.admin, - display_name='Nested', quota_bytes=10 * gb) - self.old_path = self.child.relative_path - self.file = DriveItem.objects.create(owner=self.admin, space=self.nested, - name='example.pdf', size=15, - storage_key=str(uuid.uuid4()), - storage_location=f'{self.nested.relative_path}/example.pdf') - filepath = self.storage / self.file.storage_location - filepath.parent.mkdir(parents=True, exist_ok=True) - filepath.write_bytes(b'important bytes') - - def test_move_subtree_preserves_files_and_nested_spaces(self): - res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', - {'parent_space': self.b.pk}, format='json') - self.assertEqual(res.status_code, 200, res.data) - self.child.refresh_from_db() - self.nested.refresh_from_db() - self.file.refresh_from_db() - expected = f'{self.b.relative_path}/{self.child.directory_name}' - self.assertEqual(self.child.parent_id, self.b.pk) - self.assertEqual(self.child.relative_path, expected) - self.assertEqual(self.nested.relative_path, f'{expected}/{self.nested.directory_name}') - self.assertEqual(self.file.storage_location, - f'{self.nested.relative_path}/example.pdf') - self.assertEqual((self.storage / self.file.storage_location).read_bytes(), b'important bytes') - self.assertFalse((self.storage / self.old_path).exists()) - self.assertEqual(occupancy(self.b)[0], 15) - self.assertEqual(occupancy(self.a)[0], 0) - - def test_move_refused_if_it_would_create_cycle(self): - res = self.client.patch(f'/api/drive/spaces/{self.a.pk}/', - {'parent_space': self.nested.pk}, format='json') - self.assertEqual(res.status_code, 400, res.data) - self.a.refresh_from_db() - self.assertEqual(self.a.parent_id, self.root.pk) - self.assertTrue((self.storage / self.old_path).exists()) - - def test_move_rolls_back_when_new_quota_is_insufficient(self): - res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', - {'parent_space': self.b.pk, 'quota_bytes': 0}, format='json') - self.assertEqual(res.status_code, 400, res.data) - self.child.refresh_from_db() - self.file.refresh_from_db() - self.assertEqual(self.child.parent_id, self.a.pk) - self.assertEqual(self.child.relative_path, self.old_path) - self.assertTrue((self.storage / self.file.storage_location).is_file()) - - def test_manage_space_without_create_right_on_target_cannot_move(self): - SpaceGrant.objects.create(user=self.member, space=self.child, - permissions=['view','manage_space']) - SpaceGrant.objects.create(user=self.member, space=self.b, - permissions=['view']) - self.client.force_authenticate(self.member) - res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', - {'parent_space': self.b.pk}, format='json') - self.assertEqual(res.status_code, 403, res.data) - self.child.refresh_from_db() - self.assertEqual(self.child.parent_id, self.a.pk) - - def test_private_space_keeps_its_separate_physical_storage(self): - SpaceGrant.objects.create(user=self.member, space=self.child, - permissions=['view','download']) - private = ensure_private_space(self.member) - self.assertTrue(private.relative_path.startswith('privates/')) - res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', - {'parent_space': self.b.pk}, format='json') - self.assertEqual(res.status_code, 200, res.data) - private.refresh_from_db() - self.assertEqual(private.parent_id, self.child.pk) - self.assertTrue((self.storage/private.relative_path).is_dir()) - - def test_assigned_space_move_does_not_grant_parent_permissions(self): - assigned = _make_numbered_space(self.a, user=self.member, display_name='Assigned', - quota_bytes=10 * 1024**3, can_view_parent=True, - can_write_parent=True, can_invite=True, - parent_permissions=['view','download','add','manage_access']) - self.assertIn('manage_access', space_rights(self.member,self.a)) - res = self.client.patch(f'/api/drive/spaces/{assigned.pk}/', - {'parent_space':self.b.pk}, format='json') - self.assertEqual(res.status_code,200,res.data) - assigned.refresh_from_db() - self.assertFalse(assigned.can_view_parent) - self.assertFalse(assigned.can_invite) - self.assertFalse(space_rights(self.member,self.b)) - - def test_move_is_blocked_by_occupied_destination_quota(self): - huge = 50 * 1024**3 - DriveItem.objects.create(owner=self.admin,space=self.b,name='full.bin',size=huge, - storage_key=str(uuid.uuid4()),storage_location='legacy-file') - res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', - {'parent_space':self.b.pk},format='json') - self.assertEqual(res.status_code,400,res.data) - self.child.refresh_from_db() - self.assertEqual(self.child.parent_id,self.a.pk) - self.assertTrue((self.storage/self.old_path).is_dir()) - - def test_admin_edits_direct_and_assigned_access_without_creating_duplicates(self): - assigned = _make_numbered_space(self.root, user=self.member, - display_name='Assigned', quota_bytes=10 * 1024**3) - listing = self.client.get(f'/api/drive/admin/users/{self.member.pk}/access/') - self.assertEqual(listing.status_code, 200, listing.data) - self.assertEqual(sum(a['space_id'] == assigned.pk for a in listing.data['accesses']), 1) - url = f'/api/drive/admin/users/{self.member.pk}/access/owner/{assigned.pk}/' - saved = self.client.patch(url, {'permissions':['view','download']}, format='json') - self.assertEqual(saved.status_code, 200, saved.data) - self.assertEqual(space_rights(self.member, assigned), {'view','download'}) - denied = self.client.patch(url, {'permissions':['modify']}, format='json') - self.assertEqual(denied.status_code, 400, denied.data) - listing = self.client.get(f'/api/drive/admin/users/{self.member.pk}/access/') - self.assertEqual(sum(a['space_id'] == assigned.pk for a in listing.data['accesses']), 1) - self.assertEqual(next(access['permissions'] for access in listing.data['accesses'] - if access['space_id'] == assigned.pk), ['view','download']) - revoked = self.client.delete(url) - self.assertEqual(revoked.status_code, 204) - self.assertFalse(SpaceGrant.objects.filter(user=self.member,space=assigned).exists()) diff --git a/backend/drive/tests.py b/backend/drive/tests.py deleted file mode 100644 index 8456078..0000000 --- a/backend/drive/tests.py +++ /dev/null @@ -1,624 +0,0 @@ -"""Regression tests for private spaces, delegation and legacy Drive operations. - -Run with: USE_SQLITE=true python manage.py test drive -""" -import tempfile -from pathlib import Path -from django.contrib.auth import get_user_model -from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase, override_settings -from rest_framework.test import APIClient -from .models import DriveItem, DriveSpace, SpaceInvitation, SpaceGrant - - -class HierarchicalDriveTests(TestCase): - def setUp(self): - self.tmp=tempfile.TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - self.overrides=override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name), - FILE_UPLOAD_TEMP_DIR=str(Path(self.tmp.name)/'tmp')) - self.overrides.enable();self.addCleanup(self.overrides.disable) - User=get_user_model() - self.admin=User.objects.create_superuser('admin', 'admin@example.test','Safe-password123!') - self.client=APIClient() - self.as_user(self.admin) - self.root=self.client.get('/api/auth/me/').data['space_id'] - - def as_user(self,user): - self.client.force_authenticate(user=user) - - def invite(self,**rights): - result=self.client.post('/api/drive/invitations/',rights,format='json') - self.assertEqual(result.status_code,201,result.data) - return result.data['token'] - - def accept(self,token,username): - self.as_user(None) - result=self.client.post(f'/api/invitations/{token}/accept/', - {'username':username,'password':'Safe-password123!', 'display_name':username+' perso', - 'first_name':username.title(), 'last_name':'Test'},format='json') - self.assertEqual(result.status_code,201,result.data) - return get_user_model().objects.get(username=username) - - def folder(self,space_id=None,parent=None,name='Documents'): - data={'name':name,'parent':parent} - if space_id:data['space']=space_id - return self.client.post('/api/drive/folders/',data,format='json') - - def test_global_trash_across_visible_spaces_without_private_leaks(self): - self.as_user(self.admin) - token=self.invite(can_view_parent=True) - alice=self.accept(token,'alice') - token=self.invite() - bob=self.accept(token,'bob') - self.as_user(self.admin) - root_item=self.folder(space_id=self.root,name='root deleted').data['id'] - self.client.post(f'/api/drive/items/{root_item}/trash/') - self.as_user(alice) - personal_item=self.folder(name='alice deleted').data['id'] - self.client.post(f'/api/drive/items/{personal_item}/trash/') - self.as_user(bob) - secret_item=self.folder(name='bob secret').data['id'] - self.client.post(f'/api/drive/items/{secret_item}/trash/') - self.as_user(alice) - # Selecting any space still shows the entire readable trash. - result=self.client.get('/api/drive/items/',{'view':'trash','space':self.root}) - self.assertEqual(result.status_code,200,result.data) - names={item['name'] for item in result.data['items']} - self.assertIn('root deleted',names) - self.assertIn('alice deleted',names) - self.assertNotIn('bob secret',names) - self.as_user(self.admin) - names={item['name'] for item in self.client.get('/api/drive/items/',{'view':'trash'}).data['items']} - self.assertNotIn('alice deleted',names) - self.assertNotIn('bob secret',names) - - def test_standalone_shared_space_creation_and_management(self): - self.as_user(self.admin) - created=self.client.post('/api/drive/spaces/create/', - {'display_name':'Projets communs','parent_space':self.root,'quota_bytes':None},format='json') - self.assertEqual(created.status_code,201,created.data) - space_id=created.data['id'] - space=DriveSpace.objects.get(pk=space_id) - self.assertEqual(space.managed_by,self.admin) - self.assertRegex(space.directory_name,r'^\d{4,}$') - self.assertTrue((Path(self.tmp.name)/space.relative_path).is_dir()) - self.assertEqual(self.folder(space_id,name='Équipe').status_code,201) - updated=self.client.patch(f'/api/drive/spaces/{space_id}/',{'display_name':'Archives équipe'},format='json') - self.assertEqual(updated.status_code,200,updated.data) - self.assertEqual(updated.data['display_name'],'Archives équipe') - alice=self.accept(self.invite(),'alice') - self.as_user(alice) - self.assertEqual(self.client.get('/api/drive/items/',{'space':space_id}).status_code,403) - self.assertEqual(self.client.patch(f'/api/drive/spaces/{space_id}/',{'display_name':'Piraté'},format='json').status_code,403) - - def test_signed_preview_supports_byte_ranges_and_checks_revoked_permissions(self): - self.as_user(self.admin) - new=self.client.post('/api/drive/spaces/create/',{'display_name':'Vidéos'},format='json').data - created=self.client.post('/api/drive/uploads/',{'space':new['id'],'files':[SimpleUploadedFile('clip.mp4',b'0123456789')]},format='multipart') - self.assertEqual(created.status_code,201,created.data) - item_id=created.data['items'][0]['id'] - alice=self.accept(self.invite(),'alice') - self.as_user(self.admin) - SpaceGrant.objects.create(user=alice,space_id=new['id'],can_write=False) - self.as_user(alice) - token_url=self.client.post(f'/api/drive/items/{item_id}/link/',{'inline':True},format='json') - self.assertEqual(token_url.status_code,200,token_url.data) - response=self.client.get(token_url.data['url'],HTTP_RANGE='bytes=2-5') - self.assertEqual(response.status_code,206) - self.assertEqual(response['Content-Range'],'bytes 2-5/10') - self.assertEqual(b''.join(response.streaming_content),b'2345') - grant=SpaceGrant.objects.get(user=alice,space_id=new['id']) - grant.delete() - self.assertEqual(self.client.get(token_url.data['url']).status_code,404) - - def test_numbered_directories_and_parent_readonly(self): - token=self.invite(can_view_parent=True,can_write_parent=False) - child=self.accept(token,'alice') - space=DriveSpace.objects.get(user=child) - self.assertRegex(space.directory_name, r'^\d{4,}$') - self.assertEqual(space.relative_path, f'root/{space.directory_name}') - self.assertTrue((Path(self.tmp.name)/space.relative_path).is_dir()) - self.as_user(self.admin) - folder=self.folder(name='Racine').data['id'] - self.as_user(child) - self.assertEqual(self.client.get(f'/api/drive/items/?space={self.root}').status_code,200) - self.assertEqual(self.folder(self.root,name='Interdit').status_code,403) - self.assertEqual(self.client.patch(f'/api/drive/items/{folder}/',{'name':'Fail'},format='json').status_code,403) - self.assertEqual(self.folder(space.pk,name='Partagé').status_code,201) - - def test_private_sibling_cannot_be_read_or_listed(self): - self.as_user(self.admin);a=self.invite() - alice=self.accept(a,'alice') - self.as_user(self.admin);b=self.invite() - bob=self.accept(b,'bob') - self.as_user(alice) - folder=self.folder(name='Secret') - self.assertEqual(folder.status_code,201) - alice_space=DriveSpace.objects.get(user=alice) - self.as_user(bob) - self.assertEqual(self.client.get(f'/api/drive/items/?space={alice_space.pk}').status_code,403) - self.assertEqual(self.client.get(f'/api/drive/items/?parent={folder.data["id"]}').status_code,403) - self.as_user(self.admin) - self.assertEqual(self.client.get(f'/api/drive/items/?space={alice_space.pk}').status_code,200) - - def test_delegate_invitations_and_revocation(self): - self.as_user(self.admin) - first=self.invite(can_view_parent=True,can_invite=True) - alice=self.accept(first,'alice') - self.as_user(alice) - second=self.invite(can_view_parent=True) - bob=self.accept(second,'bob') - bspace=DriveSpace.objects.get(user=bob) - self.assertEqual(bspace.parent.user,alice) - self.assertEqual(bspace.relative_path, f'{DriveSpace.objects.get(user=alice).relative_path}/{bspace.directory_name}') - self.as_user(bob) - self.assertEqual(self.client.post('/api/drive/invitations/',{},format='json').status_code,403) - self.as_user(alice) - pending=self.invite() - self.as_user(self.admin) - space=DriveSpace.objects.get(user=alice) - denied=self.client.patch(f'/api/drive/spaces/members/{space.pk}/',{'can_invite':False},format='json') - self.assertEqual(denied.status_code,200) - self.as_user(None) - self.assertEqual(self.client.get(f'/api/invitations/{pending}/').status_code,404) - - def test_parent_edit_permission_and_no_cross_space_move(self): - self.as_user(self.admin) - token=self.invite(can_view_parent=True,can_write_parent=True) - child=self.accept(token,'alice') - self.as_user(child) - folder=self.folder(self.root,name='Sur racine') - self.assertEqual(folder.status_code,201) - own=self.folder(name='Perso') - self.assertEqual(own.status_code,201) - moved=self.client.patch(f'/api/drive/items/{own.data["id"]}/',{'parent':folder.data['id']},format='json') - self.assertEqual(moved.status_code,403) - - def test_upload_storage_and_signed_access(self): - self.as_user(self.admin) - token=self.invite() - alice=self.accept(token,'alice') - self.as_user(alice) - res=self.client.post('/api/drive/uploads/',{'files':[SimpleUploadedFile('a.txt',b'hello')]},format='multipart') - self.assertEqual(res.status_code,201,res.data) - item=DriveItem.objects.get(pk=res.data['items'][0]['id']) - self.assertTrue(item.storage_location.startswith(DriveSpace.objects.get(private_owner=alice).relative_path + '/')) - self.assertTrue((Path(self.tmp.name)/item.storage_location).exists()) - url=self.client.post(f'/api/drive/items/{item.pk}/link/',{},format='json').data['url'] - self.assertEqual(b''.join(self.client.get(url).streaming_content),b'hello') - self.as_user(self.admin) - self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/link/').status_code,403) - - def test_invitation_one_time_only_and_rename_does_not_move_directory(self): - self.as_user(self.admin);token=self.invite() - alice=self.accept(token,'alice') - self.as_user(None) - self.assertEqual(self.client.get(f'/api/invitations/{token}/').status_code,404) - self.assertEqual(self.client.post(f'/api/invitations/{token}/accept/',{},format='json').status_code,400) - self.as_user(alice) - space=DriveSpace.objects.get(user=alice) - original=space.relative_path - changed=self.client.patch('/api/drive/spaces/me/',{'display_name':'Vacances'},format='json') - self.assertEqual(changed.status_code,200) - space.refresh_from_db() - self.assertEqual(space.display_name,'Vacances') - self.assertEqual(space.relative_path,original) - - def test_cannot_grant_parent_write_without_read(self): - self.as_user(self.admin) - res=self.client.post('/api/drive/invitations/',{'can_view_parent':False,'can_write_parent':True},format='json') - self.assertEqual(res.status_code,400) - - def test_legacy_starred_and_recent_views_not_available(self): - self.as_user(self.admin) - for view in ('starred', 'recent'): - with self.subTest(view=view): - result = self.client.get('/api/drive/items/', {'view': view}) - self.assertEqual(result.status_code, 400) - - def test_personal_files_are_private_even_when_shared_space_is_writable(self): - self.as_user(self.admin) - alice = self.accept(self.invite(can_view_parent=True,can_write_parent=True),'alice') - self.as_user(self.admin) - bob = self.accept(self.invite(can_view_parent=True,can_write_parent=True),'bob') - private_alice=DriveSpace.objects.get(private_owner=alice) - assigned_alice=DriveSpace.objects.get(user=alice) - self.as_user(alice) - secret=self.folder(name='Secret personnel') - self.assertEqual(secret.status_code,201,secret.data) - self.assertEqual(DriveItem.objects.get(pk=secret.data['id']).space,private_alice) - self.assertEqual(self.folder(assigned_alice.pk,name='Dossier partagé').status_code,201) - self.as_user(bob) - self.assertEqual(self.client.get('/api/drive/items/',{'space':private_alice.pk}).status_code,403) - self.assertEqual(self.client.get('/api/drive/items/',{'space':assigned_alice.pk}).status_code,403) - self.assertEqual(self.client.patch(f'/api/drive/items/{secret.data["id"]}/',{'name':'Intrusion'},format='json').status_code,403) - self.as_user(self.admin) - self.assertEqual(self.client.get('/api/drive/items/',{'space':private_alice.pk}).status_code,403) - self.assertEqual(self.client.get('/api/drive/stats/',{'space':private_alice.pk}).status_code,403) - self.assertEqual(self.client.post(f'/api/drive/items/{secret.data["id"]}/link/',{},format='json').status_code,403) - self.assertEqual(self.client.patch(f'/api/drive/items/{secret.data["id"]}/',{'name':'Intrusion admin'},format='json').status_code,403) - self.assertEqual(self.client.post(f'/api/drive/items/{secret.data["id"]}/trash/').status_code,403) - self.assertEqual(self.client.patch(f'/api/drive/spaces/{private_alice.pk}/quota/',{'quota_bytes':100},format='json').status_code,403) - self.assertNotIn(private_alice.pk,[s['id'] for s in self.client.get('/api/drive/spaces/').data['spaces']]) - self.as_user(alice) - self.assertEqual(self.client.get('/api/drive/items/',{'space':private_alice.pk}).status_code,200) - self.assertEqual(self.client.post(f'/api/drive/items/{secret.data["id"]}/trash/').status_code,200) - - def test_direct_user_access_can_be_granted_and_revoked(self): - self.as_user(self.admin) - alice=self.accept(self.invite(can_invite=True),'alice') - self.as_user(self.admin) - bob=self.accept(self.invite(),'bob') - alice_shared=DriveSpace.objects.get(user=alice) - alice_private=DriveSpace.objects.get(private_owner=alice) - self.as_user(alice) - search=self.client.get('/api/drive/spaces/users/search/',{'q':'bo'}) - self.assertEqual(search.status_code,200,search.data) - self.assertEqual(search.data['users'][0]['first_name'],'Bob') - self.assertEqual(search.data['users'][0]['last_name'],'Test') - grant=self.client.post('/api/drive/spaces/users/',{'user_id':bob.pk,'can_write':False},format='json') - self.assertEqual(grant.status_code,201,grant.data) - self.as_user(bob) - self.assertEqual(self.client.get('/api/drive/items/',{'space':alice_shared.pk}).status_code,200) - self.assertEqual(self.folder(alice_shared.pk,name='No write').status_code,403) - self.assertEqual(self.client.get('/api/drive/items/',{'space':alice_private.pk}).status_code,403) - self.as_user(alice) - changed=self.client.patch(f'/api/drive/spaces/users/{grant.data["id"]}/',{'can_write':True},format='json') - self.assertEqual(changed.status_code,200,changed.data) - self.as_user(bob) - self.assertEqual(self.folder(alice_shared.pk,name='Now writable').status_code,201) - self.as_user(alice) - self.assertEqual(self.client.delete(f'/api/drive/spaces/users/{grant.data["id"]}/').status_code,204) - self.as_user(bob) - self.assertEqual(self.client.get('/api/drive/items/',{'space':alice_shared.pk}).status_code,403) - - def test_registration_names_and_virtual_names_only(self): - self.as_user(self.admin) - token=self.invite() - self.as_user(None) - incomplete=self.client.post(f'/api/invitations/{token}/accept/',{ - 'username':'no_name','password':'Safe-password123!', 'display_name':'Mon espace'},format='json') - self.assertEqual(incomplete.status_code,400) - person=self.accept(token,'alice') - self.as_user(person) - me=self.client.get('/api/auth/me/').data - self.assertEqual(me['first_name'],'Alice') - self.assertEqual(me['last_name'],'Test') - all_spaces=self.client.get('/api/drive/spaces/').data - self.assertEqual(all_spaces['mine'],me['private_space_id']) - self.assertEqual(all_spaces['assigned'],me['space_id']) - self.assertNotEqual(all_spaces['mine'],all_spaces['assigned']) - self.assertTrue(all('directory' not in space and 'relative_path' not in space for space in all_spaces['spaces'])) - -class QuotaTests(HierarchicalDriveTests): - """Quotas apply to descendants, trash and reservations, not just visible files.""" - - def upload_small(self, contents, space_id=None): - data={'files':[SimpleUploadedFile('data.bin',contents)]} - if space_id is not None:data['space']=space_id - return self.client.post('/api/drive/uploads/',data,format='multipart') - - def test_invitation_quota_enforced_direct_and_trash(self): - token=self.invite(quota_bytes=5) - person=self.accept(token,'limited') - space=DriveSpace.objects.get(user=person) - self.assertEqual(space.quota_bytes,5) - self.as_user(person) - first=self.upload_small(b'1234') - self.assertEqual(first.status_code,201,first.data) - stat=self.client.get('/api/drive/stats/').data - self.assertEqual(stat['used_bytes'],4) - self.assertEqual(stat['available_bytes'],1) - self.assertEqual(self.upload_small(b'89').status_code,413) - item_id=first.data['items'][0]['id'] - self.assertEqual(self.client.post(f'/api/drive/items/{item_id}/trash/').status_code,200) - # Trashed files are still physically stored and consume quota. - self.assertEqual(self.client.get('/api/drive/stats/').data['used_bytes'],4) - self.assertEqual(self.upload_small(b'89').status_code,413) - self.assertEqual(self.client.delete(f'/api/drive/items/{item_id}/delete/').status_code,204) - self.assertEqual(self.client.get('/api/drive/stats/').data['available_bytes'],5) - self.assertEqual(self.upload_small(b'89').status_code,201) - - def test_resumable_upload_reserves_entire_file(self): - user=self.accept(self.invite(quota_bytes=10),'uploader') - self.as_user(user) - begin=self.client.post('/api/drive/uploads/start/',{'name':'video.bin','size':8},format='json') - self.assertEqual(begin.status_code,201,begin.data) - upload_id=begin.data['id'] - self.assertEqual(self.client.get('/api/drive/stats/').data['reserved_bytes'],8) - self.assertEqual(self.upload_small(b'abc').status_code,413) - self.assertEqual(self.client.post('/api/drive/uploads/start/',{'name':'second','size':3},format='json').status_code,413) - self.assertEqual(self.client.delete(f'/api/drive/uploads/{upload_id}/cancel/').status_code,204) - self.assertEqual(self.client.get('/api/drive/stats/').data['reserved_bytes'],0) - self.assertEqual(self.upload_small(b'abc').status_code,201) - - def test_parent_quota_includes_nested_spaces(self): - alice=self.accept(self.invite(quota_bytes=9,can_invite=True),'alice') - self.as_user(alice) - bob=self.accept(self.invite(quota_bytes=20),'bob') - self.as_user(bob) - self.assertEqual(self.upload_small(b'x'*7).status_code,201) - data=self.client.get('/api/drive/stats/').data - self.assertEqual(data['quota_bytes'],20) - self.assertEqual(data['available_bytes'],2) # parent's 9-byte cap - self.assertEqual(self.upload_small(b'y'*3).status_code,413) - self.as_user(alice) - self.assertEqual(self.client.get('/api/drive/stats/',{'space':DriveSpace.objects.get(user=alice).pk}).data['used_bytes'],7) - self.assertEqual(self.upload_small(b'z'*2).status_code,201) - self.assertEqual(self.upload_small(b'z').status_code,413) - - def test_manager_can_change_child_quota_but_not_below_used(self): - alice=self.accept(self.invite(can_invite=True),'alice') - self.as_user(alice) - bob=self.accept(self.invite(quota_bytes=5),'bob') - space=DriveSpace.objects.get(user=bob) - self.as_user(bob) - self.assertEqual(self.upload_small(b'1234').status_code,201) - self.as_user(alice) - endpoint=f'/api/drive/spaces/members/{space.pk}/' - self.assertEqual(self.client.patch(endpoint,{'quota_bytes':3},format='json').status_code,400) - self.assertEqual(self.client.patch(endpoint,{'quota_bytes':8},format='json').status_code,200) - space.refresh_from_db();self.assertEqual(space.quota_bytes,8) - self.assertEqual(self.client.patch(endpoint,{'quota_bytes':None},format='json').status_code,200) - self.assertEqual(self.client.patch(endpoint,{'quota_bytes':-1},format='json').status_code,400) - self.as_user(bob) - self.assertEqual(self.client.patch(endpoint,{'quota_bytes':0},format='json').status_code,403) - - def test_admin_root_quota_caps_everyone(self): - self.as_user(self.admin) - endpoint=f'/api/drive/spaces/{self.root}/quota/' - self.assertEqual(self.client.patch(endpoint,{'quota_bytes':4},format='json').status_code,200) - person=self.accept(self.invite(quota_bytes=None),'someone') - self.as_user(person) - self.assertEqual(self.upload_small(b'1234').status_code,201) - self.assertEqual(self.upload_small(b'5').status_code,413) - self.as_user(self.admin) - self.assertEqual(self.client.patch(endpoint,{'quota_bytes':2},format='json').status_code,400) - -class TransferAndSharedRefreshTests(HierarchicalDriveTests): - """Uploads can be aborted without leaving a quota reservation or a file.""" - - def test_start_chunk_cancel_frees_quota(self): - self.as_user(self.admin) - started=self.client.post('/api/drive/uploads/start/', - {'name':'gros.zip','size':12,'space':self.root},format='json') - self.assertEqual(started.status_code,201,started.data) - job_id=started.data['id'] - chunk=self.client.post(f'/api/drive/uploads/{job_id}/chunk/', - {'offset':'0','part':SimpleUploadedFile('bloc.bin',b'abc')},format='multipart') - self.assertEqual(chunk.status_code,200,chunk.data) - self.assertEqual(chunk.data['offset'],3) - progress=self.client.get(f'/api/drive/uploads/{job_id}/') - self.assertEqual(progress.data['offset'],3) - with self.captureOnCommitCallbacks(execute=True): - canceled=self.client.delete(f'/api/drive/uploads/{job_id}/cancel/') - self.assertEqual(canceled.status_code,204) - self.assertFalse((Path(self.tmp.name)/'tmp'/f'drive-{job_id}.part').exists()) - self.assertEqual(self.client.get(f'/api/drive/uploads/{job_id}/').status_code,404) - self.assertEqual(self.client.get(f'/api/drive/stats/?space={self.root}').data['reserved_bytes'],0) - self.assertFalse(DriveItem.objects.filter(name='gros.zip').exists()) - - def test_shared_folder_listing_reflects_other_users_trash(self): - self.as_user(self.admin) - token=self.invite(can_view_parent=True,can_write_parent=True) - child=self.accept(token,'member') - self.as_user(self.admin) - folder=self.folder(space_id=self.root,name='Commun') - self.assertEqual(folder.status_code,201) - folder_id=folder.data['id'] - self.as_user(child) - self.assertIn(folder_id, [x['id'] for x in self.client.get(f'/api/drive/items/?space={self.root}').data['items']]) - self.as_user(self.admin) - self.assertEqual(self.client.post(f'/api/drive/items/{folder_id}/trash/').status_code,200) - self.as_user(child) - self.assertNotIn(folder_id, [x['id'] for x in self.client.get(f'/api/drive/items/?space={self.root}').data['items']]) - - -class PrivacyAndStorageTests(HierarchicalDriveTests): - def test_admin_cannot_read_private_upload_content_or_metadata(self): - alice = self.accept(self.invite(), 'alice') - self.as_user(alice) - uploaded = self.client.post('/api/drive/uploads/', { - 'files': [SimpleUploadedFile('private.txt', b'secret-data')] - }, format='multipart') - self.assertEqual(uploaded.status_code, 201, uploaded.data) - pk = uploaded.data['items'][0]['id'] - private_id = DriveSpace.objects.get(private_owner=alice).pk - self.as_user(self.admin) - self.assertEqual(self.client.get('/api/drive/items/', {'space': private_id}).status_code, 403) - self.assertEqual(self.client.post(f'/api/drive/items/{pk}/link/', {}, format='json').status_code, 403) - self.assertEqual(self.client.delete(f'/api/drive/items/{pk}/delete/').status_code, 403) - # Direct Django-admin listing of private DriveItem/UploadSession models is disabled. - from django.contrib import admin - from .models import UploadSession - self.assertFalse(admin.site.is_registered(DriveItem)) - self.assertFalse(admin.site.is_registered(UploadSession)) - - def test_quota_details_distinguish_live_trash_and_pending(self): - alice = self.accept(self.invite(quota_bytes=200), 'alice') - self.as_user(alice) - uploaded = self.client.post('/api/drive/uploads/', { - 'files': [SimpleUploadedFile('file.txt', b'1234567')] - }, format='multipart') - pk = uploaded.data['items'][0]['id'] - stats = lambda: self.client.get('/api/drive/stats/').data - self.assertEqual((stats()['used_bytes'], stats()['active_bytes'], stats()['trashed_bytes']), (7, 7, 0)) - self.assertEqual(self.client.post(f'/api/drive/items/{pk}/trash/').status_code, 200) - self.assertEqual((stats()['used_bytes'], stats()['active_bytes'], stats()['trashed_bytes']), (7, 0, 7)) - self.assertEqual(self.client.delete(f'/api/drive/items/{pk}/delete/').status_code, 204) - self.assertEqual(stats()['used_bytes'], 0) - self.assertEqual(stats()['trashed_bytes'], 0) - started = self.client.post('/api/drive/uploads/start/', {'name':'incomplete.bin','size':70}, format='json') - self.assertEqual(started.status_code, 201) - self.assertEqual(stats()['reserved_bytes'], 70) - self.assertEqual(self.client.delete(f'/api/drive/uploads/{started.data["id"]}/cancel/').status_code, 204) - self.assertEqual(stats()['reserved_bytes'], 0) - - def test_file_inside_trashed_folder_is_counted_as_trash(self): - alice = self.accept(self.invite(), 'alice') - self.as_user(alice) - folder = self.folder(name='À supprimer') - uploaded = self.client.post('/api/drive/uploads/', { - 'parent': folder.data['id'], 'files': [SimpleUploadedFile('nested.txt', b'hello')] - }, format='multipart') - self.assertEqual(uploaded.status_code, 201, uploaded.data) - self.client.post(f'/api/drive/items/{folder.data["id"]}/trash/') - result = self.client.get('/api/drive/stats/').data - self.assertEqual(result['used_bytes'], 5) - self.assertEqual(result['active_bytes'], 0) - self.assertEqual(result['trashed_bytes'], 5) - -class InterruptedUploadTests(HierarchicalDriveTests): - def test_pending_uploads_belong_only_to_owner(self): - alice = self.accept(self.invite(), 'alice') - self.as_user(alice) - started = self.client.post('/api/drive/uploads/start/', { - 'name': 'unfinished.txt', 'size': 71_680 - }, format='json') - self.assertEqual(started.status_code, 201, started.data) - pending = self.client.get('/api/drive/uploads/pending/') - self.assertEqual(pending.status_code, 200) - self.assertEqual(pending.data['sessions'][0]['size'], 71_680) - self.as_user(self.admin) - self.assertEqual(self.client.get('/api/drive/uploads/pending/').data['sessions'], []) - self.as_user(alice) - self.assertEqual(self.client.delete(f'/api/drive/uploads/{started.data["id"]}/cancel/').status_code, 204) - self.assertEqual(self.client.get('/api/drive/uploads/pending/').data['sessions'], []) - - def test_sibling_quotas_cannot_overallocate_parent(self): - self.as_user(self.admin) - self.assertEqual(self.client.patch(f'/api/drive/spaces/{self.root}/quota/', - {'quota_bytes':10_000},format='json').status_code,200) - first=self.client.post('/api/drive/spaces/create/', - {'display_name':'Premier','parent_space':self.root,'quota_bytes':7_000},format='json') - self.assertEqual(first.status_code,201,first.data) - second=self.client.post('/api/drive/spaces/create/', - {'display_name':'Deuxième','parent_space':self.root,'quota_bytes':4_000},format='json') - self.assertEqual(second.status_code,400,second.data) - smaller=self.client.post('/api/drive/spaces/create/', - {'display_name':'Deuxième','parent_space':self.root,'quota_bytes':3_000},format='json') - self.assertEqual(smaller.status_code,201,smaller.data) - rejected=self.client.patch(f'/api/drive/spaces/{first.data["id"]}/', - {'quota_bytes':8_000},format='json') - self.assertEqual(rejected.status_code,400,rejected.data) - - def test_unlimited_child_inherits_parent_remaining(self): - self.as_user(self.admin) - self.client.patch(f'/api/drive/spaces/{self.root}/quota/',{'quota_bytes':10_000},format='json') - child=self.client.post('/api/drive/spaces/create/', - {'display_name':'Sans limite propre','parent_space':self.root,'quota_bytes':None},format='json') - self.assertEqual(child.status_code,201,child.data) - self.assertIsNone(child.data['quota_bytes']) - self.assertEqual(child.data['available_bytes'],10_000) - self.assertEqual(self.client.post('/api/drive/spaces/create/', - {'display_name':'Réservation','parent_space':self.root,'quota_bytes':4_000},format='json').status_code,201) - stats=self.client.get('/api/drive/stats/',{'space':child.data['id']}) - self.assertEqual(stats.status_code,200,stats.data) - self.assertEqual(stats.data['available_bytes'],6_000) - self.assertEqual(self.client.post('/api/drive/uploads/',{'space':child.data['id'], - 'files':[SimpleUploadedFile('too-big.bin',b'x'*6_001)]},format='multipart').status_code,413) - - def test_parent_cannot_be_reduced_below_allocated_children(self): - self.as_user(self.admin) - self.client.post('/api/drive/spaces/create/', - {'display_name':'Réservation','parent_space':self.root,'quota_bytes':1000},format='json') - result=self.client.patch(f'/api/drive/spaces/{self.root}/quota/', - {'quota_bytes':999},format='json') - self.assertEqual(result.status_code,400,result.data) - - -class SharedSpaceDeletionTests(TestCase): - """Deleting managed spaces may never erase an attached user's data.""" - - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - override = override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name), - FILE_UPLOAD_TEMP_DIR=str(Path(self.tmp.name) / 'tmp')) - override.enable() - self.addCleanup(override.disable) - self.admin = get_user_model().objects.create_superuser('admin', 'admin@example.test', 'Strong-pass123!') - self.client = APIClient() - self.client.force_authenticate(user=self.admin) - self.root = self.client.get('/api/auth/me/').data['space_id'] - - def create_space(self, name, parent=None): - reply = self.client.post('/api/drive/spaces/create/', - {'display_name': name, 'parent_space': parent or self.root}, format='json') - self.assertEqual(reply.status_code, 201, reply.data) - return reply.data['id'] - - def delete(self, space_id, confirmed): - return self.client.delete(f'/api/drive/spaces/{space_id}/', - {'confirm_name': confirmed}, format='json') - - def test_deleting_managed_subtree_removes_files_and_uploads(self): - from .models import UploadSession - parent = self.create_space('Projet éphémère') - child = self.create_space('Sous-projet', parent) - uploaded = self.client.post('/api/drive/uploads/', - {'space': child, 'files': [SimpleUploadedFile('notes.txt', b'bonjour')]}, format='multipart') - self.assertEqual(uploaded.status_code, 201, uploaded.data) - item = DriveItem.objects.get(pk=uploaded.data['items'][0]['id']) - path = Path(self.tmp.name) / item.storage_location - self.assertTrue(path.exists()) - UploadSession.objects.create(owner=self.admin, space_id=child, - name='inachevé.zip', total_size=1024) - parent_path = Path(self.tmp.name) / DriveSpace.objects.get(pk=parent).relative_path - with self.captureOnCommitCallbacks(execute=True): - result = self.delete(parent, 'Projet éphémère') - self.assertEqual(result.status_code, 200, result.data) - self.assertFalse(DriveSpace.objects.filter(pk__in=[parent, child]).exists()) - self.assertFalse(DriveItem.objects.filter(pk=item.pk).exists()) - self.assertFalse(parent_path.exists()) - - def test_refuses_wrong_confirmation_root_and_attached_user_space(self): - managed = self.create_space('Confidentiel') - self.assertEqual(self.delete(managed, 'autre').status_code, 400) - self.assertEqual(self.delete(self.root, 'Racine').status_code, 403) - from .spaces import ensure_private_space, create_space - assigned = create_space(get_user_model().objects.create_user('alice', password='Strong-pass123!'), - DriveSpace.objects.get(pk=managed), 'Alice') - ensure_private_space(assigned.user) - response = self.delete(managed, 'Confidentiel') - self.assertEqual(response.status_code, 409, response.data) - self.assertTrue(DriveSpace.objects.filter(pk=managed).exists()) - self.assertTrue(DriveSpace.objects.filter(pk=assigned.pk).exists()) - - def test_non_manager_cannot_delete_managed_space(self): - managed = self.create_space('Team') - alice = get_user_model().objects.create_user('alice', password='Strong-pass123!') - self.client.force_authenticate(user=alice) - self.assertEqual(self.delete(managed, 'Team').status_code, 403) - - -class BootstrapAdminCommandTests(TestCase): - def test_bootstraps_from_env_only_when_no_superuser_exists(self): - from unittest.mock import patch - from django.core.management import call_command - env = {'ADMIN_USERNAME': 'rootuser', 'ADMIN_PASSWORD': 'Safe-pass123!', - 'ADMIN_FIRST_NAME': 'Ada', 'ADMIN_LAST_NAME': 'Test', - 'ADMIN_EMAIL': 'root@example.test'} - with patch.dict('os.environ', env): - call_command('ensure_admin') - account = get_user_model().objects.get(username='rootuser') - self.assertTrue(account.is_superuser) - self.assertTrue(account.is_staff) - self.assertEqual(account.first_name, 'Ada') - self.assertTrue(account.check_password('Safe-pass123!')) - with patch.dict('os.environ', {'ADMIN_PASSWORD': 'Different-pass!'}): - call_command('ensure_admin') - account.refresh_from_db() - self.assertTrue(account.check_password('Safe-pass123!')) - self.assertEqual(get_user_model().objects.filter(is_superuser=True).count(), 1) - - def test_existing_username_is_not_promoted_without_permission(self): - from unittest.mock import patch - from django.core.management import call_command - user = get_user_model().objects.create_user('rootuser', password='Current-pass123!') - with patch.dict('os.environ', {'ADMIN_USERNAME': 'rootuser', 'ADMIN_PASSWORD': 'Other-pass123!'}): - call_command('ensure_admin') - user.refresh_from_db() - self.assertFalse(user.is_superuser) - self.assertTrue(user.check_password('Current-pass123!')) diff --git a/frontend/src/components/drive/DriveExplorer.jsx b/frontend/src/components/drive/DriveExplorer.jsx index 23984bc..0ec36d4 100644 --- a/frontend/src/components/drive/DriveExplorer.jsx +++ b/frontend/src/components/drive/DriveExplorer.jsx @@ -29,6 +29,12 @@ export default function DriveExplorer({ controller, user }) { }, [display, context, closeContext]) useEffect(() => { closeContext() }, [view, activeSpace?.id, closeContext]) + // In "Partagés avec moi", space statistics belong to the last selected + // space. Count the displayed shared files and folders instead. + const visibleCounts = loading ? null : view === 'shared' + ? { folders: display.filter(item => item.is_folder).length, files: display.filter(item => !item.is_folder).length } + : view === 'files' ? stats : null + const title = search ? `« ${search} »` : trail.length && !search ? trail.at(-1)?.name : view === 'files' ? (activeSpace?.display_name || headerNames[view]) : (view === 'shared' ? 'Partagés avec moi' : headerNames[view]) @@ -119,7 +125,7 @@ export default function DriveExplorer({ controller, user }) { onChoose={type => performItemAction(context.item,type)} /> ) diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index c276b73..70ff9b9 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -112,7 +112,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
-
+ {view === 'files' && activeSpace &&
Stockage de l’espace @@ -131,7 +131,7 @@ export default function DriveSidebar({ controller, user, onLogout }) { {trashBytes > 0 &&

Corbeille : {fmtSize(trashBytes)} (vider pour libérer)

} {reservedBytes > 0 &&

Transferts réservés : {fmtSize(reservedBytes)} (annuler pour libérer)

}
-
+
} {orphaned.length > 0 &&

Imports non terminés ({orphaned.length})

diff --git a/frontend/src/hooks/useDriveController.js b/frontend/src/hooks/useDriveController.js index 4bec744..2bc5c82 100644 --- a/frontend/src/hooks/useDriveController.js +++ b/frontend/src/hooks/useDriveController.js @@ -21,7 +21,7 @@ export function useDriveController(user) { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [note, setNote] = useState('') - const [layout, setLayout] = useState('list') + const [layout, setLayout] = useState('grid') const [sort, setSort] = useState('name') const [dark, setDark] = useState(localStorage.getItem('drive_theme') === 'dark') const [sidebar, setSidebar] = useState(false)