This commit is contained in:
Louis Mazin 2026-10-14 00:08:24 +02:00
parent ac44c6ed8b
commit ef5b14d084
18 changed files with 334 additions and 267 deletions

View File

@ -1,42 +0,0 @@
# GoodDrive — Correctifs aperçus, sessions et quotas
## Aperçus
- Les appels `/api` passent par le proxy Vite, du port frontend `1028` vers le service `backend:1029` ; l'URL signée pour l'aperçu reste sur le **même domaine**.
- L'API vérifie les permissions lors de la création du lien **et** de chaque demande de lecture, y compris les plages `Range` des vidéos.
- PDF et texte peuvent être affichés dans une iframe **de même origine** ; les autres pages restent protégées des intégrations externes.
- Les liens d'aperçu expirent après deux heures et ne dispensent jamais de la vérification des permissions. Une URL déjà ouverte pourra expirer pendant une vidéo très longue : rouvrir l'aperçu pour régénérer son lien.
- Une vérification HEAD signale un aperçu inaccessible au lieu d'afficher une page vide. Formats visés : images, PDF, audio, vidéo et textes courants ; les codecs vidéo dépendent du navigateur.
## Connexion
- `api` et renouvellement JWT utilisent la même origine `/api`.
- Le renouvellement est partagé entre requêtes concurrentes (une seule requête en cours).
- On déconnecte uniquement lorsque le serveur rejette **explicitement** le refresh token. Un problème réseau ou un backend indisponible conserve la session et permet de réessayer.
## Navigation et gestion des espaces
- Suppression de la rubrique « Espaces & invitations » de la navigation.
- Clic droit sur l'espace (bureau) ou `…` (mobile) : paramètres, accès et invitations via fenêtres dédiées.
- Le bouton `+` à côté d'« Espaces partagés » reste disponible pour créer un espace enfant.
## Quotas imbriqués
- Le calcul distingue **occupation réelle** (fichiers actifs, corbeille et uploads réservés) et **quotas attribués** aux sous-espaces.
- Chaque espace enfant avec quota réserve cette capacité au sein du quota de son parent ; les réservations ne sont jamais additionnées deux fois.
- Une limite est refusée si elle dépasse la capacité non attribuée restante du parent, ou si elle devient inférieure aux engagements des descendants.
- Un espace sans quota propre affiche son occupation et la capacité restante héritée de ses ancêtres.
- Les créations d'espaces, modifications de quotas et uploads utilisent des verrous transactionnels sur les ancêtres ; les paramètres de quota restent stockés dans les champs déjà présents, donc **aucune migration**.
- Les invitations non utilisées ne constituent pas des espaces physiques réservés. Un quota est revérifié lors de l'acceptation, au cas où la capacité disponible aurait changé.
## Déploiement
Remplacer les fichiers de l'archive puis reconstruire :
```bash
docker compose up -d --build frontend backend
```
Après redémarrage, tester en conditions réelles : un aperçu PDF, une image, une vidéo MP4 compatible navigateur, le rafraîchissement du jeton après 30 minutes, et un parent de 10 Go avec deux sous-espaces de 7 et 4 Go (le second doit être refusé).
Tests locaux effectués : analyse syntaxique JS/JSX, compilation syntaxique Python, tests Node frontend. Les tests Django avec base de données et le build Vite complet requièrent les dépendances non présentes dans cet environnement.

View File

@ -14,4 +14,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/
CMD ["python", "manage.py", "runserver", "0.0.0.0:1029"]
CMD ["sh", "/app/entrypoint.sh"]

View File

@ -0,0 +1,45 @@
"""Create the initial superuser from environment, without ever updating another."""
from django.db.models import Q
from decouple import config
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.db import IntegrityError, transaction
class Command(BaseCommand):
help = 'Crée le premier administrateur depuis ADMIN_* si aucun administrateur n’existe.'
def handle(self, *args, **options):
User = get_user_model()
if User.objects.filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
self.stdout.write('Administrateur déjà présent : aucune modification.')
return
username = config('ADMIN_USERNAME', default='').strip()
password = config('ADMIN_PASSWORD', default='')
first_name = config('ADMIN_FIRST_NAME', default='').strip()
last_name = config('ADMIN_LAST_NAME', default='').strip()
email = config('ADMIN_EMAIL', default='').strip()
if not username or not password:
self.stdout.write(self.style.WARNING('ADMIN_USERNAME ou ADMIN_PASSWORD absent : création ignorée.'))
return
try:
with transaction.atomic():
if User.objects.select_for_update().filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
return
if User.objects.filter(username=username).exists():
self.stdout.write(self.style.WARNING(
'Identifiant déjà utilisé : compte existant inchangé, création ignorée.'))
return
User.objects.create_superuser(
username=username, password=password, email=email,
first_name=first_name, last_name=last_name,
)
except IntegrityError:
# Another container could have initialized the superuser in parallel.
if User.objects.filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
self.stdout.write('Administrateur déjà initialisé par un autre processus.')
return
raise
self.stdout.write(self.style.SUCCESS('Administrateur initial créé.'))

View File

@ -526,3 +526,99 @@ class InterruptedUploadTests(HierarchicalDriveTests):
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!'))

View File

@ -1,5 +1,10 @@
"""Invitation-only registration and hierarchical space management."""
import secrets
import logging
import shutil
from pathlib import Path
from django.conf import settings
from django.db.models import Q
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
@ -10,7 +15,7 @@ from rest_framework.decorators import api_view, permission_classes, throttle_cla
from rest_framework.permissions import AllowAny
from rest_framework.throttling import AnonRateThrottle
from rest_framework.response import Response
from .models import DriveSpace, SpaceInvitation, SpaceGrant
from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
from .services import error, valid_name
from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_manage_space
from .quotas import parse_quota, locked_quota_tree, assert_new_child_quota, assert_updated_quota
@ -340,11 +345,13 @@ def create_shared_space(request):
return Response(space_info(space,request.user),status=201)
@api_view(['PATCH'])
@api_view(['PATCH','DELETE'])
def edit_shared_space(request,space_id):
try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id or not manager(request.user,space):return error('Modification interdite.',403)
if request.method == 'DELETE':
return delete_shared_space(request, space_id)
try:
with transaction.atomic():
if 'display_name' in request.data:
@ -356,3 +363,79 @@ def edit_shared_space(request,space_id):
_update_quota(space,request.data['quota_bytes'])
except ValueError as exc:return error(str(exc))
return Response(space_info(space,request.user))
_logger = logging.getLogger(__name__)
def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids):
"""Executed only AFTER commit: keep SQL changes atomic before touching disk."""
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
folder = (root / relative_path).resolve()
if folder == root or not folder.is_relative_to(root):
_logger.error('Invalid space path for disk cleanup: %s', relative_path)
return
# Old files may predate the numbered storage directories.
for location in legacy_locations:
path = (root / location).resolve()
if path.is_relative_to(root) and not path.is_relative_to(folder):
try:
path.unlink(missing_ok=True)
except OSError:
_logger.exception('Unable to remove legacy file: %s', path)
try:
if folder.exists():
shutil.rmtree(folder)
except OSError:
_logger.exception('Unable to remove deleted space directory: %s', folder)
temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve()
for upload_id in temp_ids:
try:
(temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True)
except OSError:
_logger.exception('Unable to remove canceled upload %s', upload_id)
def delete_shared_space(request, space_id):
"""Permanently delete a manually managed space and its independent descendants.
Never delete root, assigned account spaces, or ANY private space, including a
private descendant. All cleanup is committed before disk deletion starts.
"""
confirm_name = request.data.get('confirm_name')
if not isinstance(confirm_name, str):
return error('Confirme la suppression en saisissant le nom exact de l’espace.')
with transaction.atomic():
try:
space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id)
except DriveSpace.DoesNotExist:
return error('Espace introuvable.', 404)
if not space.parent_id or space.user_id or space.private_owner_id or not space.managed_by_id:
return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403)
if not manager(request.user, space):
return error('Suppression interdite.', 403)
if confirm_name != space.display_name:
return error('Le nom de confirmation ne correspond pas au nom de l’espace.')
# Also lock the parent: creation and deletion of siblings are serialized.
list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id))
descendants = list(DriveSpace.objects.select_for_update().filter(
Q(relative_path=space.relative_path) |
Q(relative_path__startswith=space.relative_path + '/')
).order_by('-relative_path'))
if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants):
return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409)
ids = [child.pk for child in descendants]
files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list(
'storage_location', 'storage_key'))
legacy_locations = [location or f'{key[:2]}/{key}'
for location, key in files if key and not location]
upload_ids = list(UploadSession.objects.select_for_update().filter(
space_id__in=ids).values_list('pk', flat=True))
# Children of DriveItem use CASCADE; item ownership and other accounts
# are left intact, only metadata from these owned spaces is deleted.
DriveItem.objects.filter(space_id__in=ids).delete()
for child in descendants:
child.delete() # bottom-up, as DriveSpace.parent uses PROTECT
transaction.on_commit(lambda: _remove_deleted_space_files(
space.relative_path, legacy_locations, upload_ids))
return Response({'deleted_space_ids': ids})

View File

@ -89,6 +89,7 @@ export const drive={
spaces:()=>api.get('/drive/spaces/').then(r=>r.data),
createSpace:data=>api.post('/drive/spaces/create/',data).then(r=>r.data),
editSpace:(id,data)=>api.patch(`/drive/spaces/${id}/`,data).then(r=>r.data),
deleteSpace:(id,confirm_name)=>api.delete(`/drive/spaces/${id}/`,{data:{confirm_name}}).then(r=>r.data),
renameSpace:display_name=>api.patch('/drive/spaces/me/',{display_name}).then(r=>r.data),
searchUsers:(q,parent_space)=>api.get('/drive/spaces/users/search/',{params:{q,parent_space}}).then(r=>r.data.users),
addUser:(user_id,parent_space,can_write=false,can_invite=false)=>api.post('/drive/spaces/users/',{user_id,can_write,can_invite},{params:{parent_space}}).then(r=>r.data),

View File

@ -22,6 +22,7 @@ export const fileOptions = (item, trash) => {
export const spaceOptions = (space) => space?.can_invite ? [
{id:'manage',title:'Paramètres de l’espace',icon:Settings2},
{id:'members',title:'Accès et invitations',icon:Users},
...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]),
] : []
/** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */

View File

@ -4,6 +4,7 @@ import DeleteDialog from './dialogs/DeleteDialog'
import PreviewDialog from './dialogs/PreviewDialog'
import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog'
import MembersDialog from './dialogs/MembersDialog'
import DeleteSpaceDialog from './dialogs/DeleteSpaceDialog'
/** Groups the Drive's existing dialogs without coupling them to the page markup. */
export default function DriveDialogs({ controller, user }) {
@ -13,6 +14,7 @@ export default function DriveDialogs({ controller, user }) {
<DeleteDialog controller={controller} />
<PreviewDialog controller={controller} />
<SpaceSettingsDialog controller={controller} />
<DeleteSpaceDialog controller={controller} />
<MembersDialog controller={controller} user={user} />
</>
}

View File

@ -23,6 +23,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
const space=spaceMenu?.space
if(!space)return
if(command==='manage')setSpaceDialog({mode:'manage',spaceId:space.id})
if(command==='delete')setSpaceDialog({mode:'delete',spaceId:space.id})
if(command==='members'){
setManagedSpaceId(space.id)

View File

@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, Trash2 } from 'lucide-react'
import { drive, message } from '@/api/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
/** Destructive operation: never allow deletion without typing the virtual name. */
export default function DeleteSpaceDialog({controller: c}) {
const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, switchSpace, loadSpaces, setError, setNote} = c
const isOpen = spaceDialog?.mode === 'delete'
const space = spaces.find(s => s.id === spaceDialog?.spaceId)
const [confirmation, setConfirmation] = useState('')
const [busy, setBusy] = useState(false)
const [localError, setLocalError] = useState('')
useEffect(() => {
setConfirmation('')
setLocalError('')
}, [spaceDialog?.spaceId, isOpen])
async function remove(event) {
event.preventDefault()
if (!space || confirmation !== space.display_name || busy) return
setBusy(true)
setLocalError('')
try {
const response = await drive.deleteSpace(space.id, confirmation)
const deleted = response.deleted_space_ids || []
if (deleted.includes(activeSpaceId)) {
const mine = spaces.find(s => s.is_private)
if (mine) switchSpace(mine.id)
}
setSpaceDialog(null)
await loadSpaces()
setNote('Espace supprimé définitivement.')
} catch (exception) {
const detail = message(exception)
setLocalError(detail)
setError(detail)
} finally { setBusy(false) }
}
return <Dialog open={isOpen} onOpenChange={open => {if (!open && !busy) setSpaceDialog(null)}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive"><Trash2 className="size-5"/> Supprimer l’espace</DialogTitle>
<DialogDescription>
L’espace « {space?.display_name} », ses fichiers, sa corbeille, ses transferts et ses sous-espaces autonomes seront supprimés définitivement.
</DialogDescription>
</DialogHeader>
<div className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-destructive"/>
<p>Action irréversible. Si l’espace contient des sous-utilisateurs avec leur propre compte, la suppression sera bloquée pour protéger leurs données privées.</p>
</div>
<form onSubmit={remove} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="confirm-delete-space">Tape « {space?.display_name} » pour confirmer</Label>
<Input id="confirm-delete-space" value={confirmation} onChange={e => setConfirmation(e.target.value)}
autoComplete="off" autoFocus disabled={busy}/>
</div>
{localError && <p role="alert" className="text-sm text-destructive">{localError}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={busy} onClick={()=>setSpaceDialog(null)}>Annuler</Button>
<Button type="submit" variant="destructive" disabled={busy || !space || confirmation !== space.display_name}>
{busy ? 'Suppression…' : 'Supprimer définitivement'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
}

View File

@ -48,7 +48,7 @@ export default function SpaceSettingsDialog({controller: c}) {
setSpaceDialog(null)
}
return <Dialog open={!!spaceDialog} onOpenChange={open=>{if(!open&&!busy)setSpaceDialog(null)}}>
return <Dialog open={!!spaceDialog && ['manage','create'].includes(spaceDialog.mode)} onOpenChange={open=>{if(!open&&!busy)setSpaceDialog(null)}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{manage ? 'Gérer l’espace' : 'Créer un espace partagé'}</DialogTitle>

View File

@ -1,27 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const source = f => readFileSync(new URL(`../${f}`, import.meta.url),'utf8')
test('all APIs and signed previews use the same Vite proxy', () => {
assert.match(source('vite.config.js'), /['"]\/api['"]\s*:/)
assert.match(source('src/api/client.js'), /baseURL:\s*['"]\/api['"]/)
assert.doesNotMatch(source('src/api/client.js'), /hostname\}:1029/)
})
test('a transient error never logs out users, refresh has a single promise', () => {
const client=source('src/api/client.js')
assert.match(client, /refreshPromise/)
assert.match(client, /if \(authError\(refreshError\)\)/)
assert.match(source('src/App.jsx'), /connectionError/)
})
test('space management and invitations use context dialogs, not a dedicated navigation tab', () => {
assert.doesNotMatch(source('src/config/navigation.js'), /members|invitations/i)
assert.match(source('src/components/drive/DriveDialogs.jsx'), /MembersDialog/)
assert.match(source('src/components/drive/ContextActions.jsx'), /Accès et invitations/)
assert.match(source('src/components/drive/DriveSidebar.jsx'), /onContextMenu/)
})
test('previews are checked before showing them and PDF can load in iframe', () => {
assert.match(source('src/hooks/useDriveController.js'), /method:'HEAD'/)
assert.match(source('src/components/drive/dialogs/PreviewDialog.jsx'), /<iframe/)
assert.match(source('../backend/drive/views_content.py'), /xframe_options_sameorigin/)
})

View File

@ -1,19 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {fromQuota,toQuota} from '../src/lib/quota-utils.js'
test('Un quota illimité est envoyé en null',()=>{
assert.equal(toQuota(''),null)
assert.equal(fromQuota(null),'')
})
test('Les Go deviennent des octets entiers',()=>{
assert.equal(toQuota('10'),10737418240)
assert.equal(toQuota('1,5'),1610612736)
assert.equal(toQuota('0'),0)
assert.equal(fromQuota(1610612736),'1.5')
})
test('Les tailles malformées ou imprécises sont refusées',()=>{
for(const value of ['-3','1e10','1.1234','abc','Infinity','10000000000']){
assert.throws(()=>toQuota(value))
}
})

View File

@ -1,31 +0,0 @@
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
import test from 'node:test'
const src = file => readFileSync(new URL(`../src/${file}`, import.meta.url), 'utf8')
test('navigation GoodDrive sans Récents ni Favoris', () => {
const nav = src('config/navigation.js')
assert.doesNotMatch(nav, /recent|starred|Récents|Favoris/)
assert.doesNotMatch(nav, /Espaces & invitations/)
assert.match(src('components/drive/ContextActions.jsx'), /Accès et invitations/)
assert.match(nav, /Corbeille/)
})
test('inscription sur invitation : soumission réelle et formulaire minimal', () => {
const page = src('pages/InvitationPage.jsx')
assert.match(page, /<form onSubmit=\{submit\}/)
assert.match(page, /<Button type="submit"/)
assert.doesNotMatch(page, /Permissions reçues/)
assert.match(page, /drive\.inviteAccept\(token/)
})
test('aucune action favorite dans la liste, la fenêtre ou le contrôleur', () => {
for (const file of [
'components/drive/FileList.jsx',
'components/drive/dialogs/ItemActionsDialog.jsx',
'hooks/useDriveController.js',
]) {
assert.doesNotMatch(src(file), /is_starred|handleAction\('star'\)/, file)
}
})

View File

@ -1,29 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { currentTransferSpeed, formatDuration, recordTransferProgress, resetTransferRate } from '../src/lib/transfer-metrics.js'
test('débit en octets/s et ETA restant calculés avec les octets transmis', () => {
const job = { status: 'uploading', size: 1_000_000, sent: 0 }
resetTransferRate(job, 1000)
recordTransferProgress(job, 100_000, 2000)
assert.equal(job.speedBps, 100_000)
assert.equal(job.etaSeconds, 9)
assert.equal(currentTransferSpeed(job, 2500), 100_000)
assert.equal(currentTransferSpeed(job, 7001), 0)
recordTransferProgress(job, 300_000, 3000)
assert.equal(job.speedBps, 165_000)
assert.equal(job.etaSeconds, 5)
})
test('annulation/pause et temps restant formatés correctement', () => {
const job = { status: 'uploading', size: 100, sent: 40, speedBps: 50, etaSeconds: 2 }
resetTransferRate(job, 2000)
assert.equal(job.speedBps, 0)
assert.equal(job.etaSeconds, null)
assert.equal(formatDuration(null), 'Calcul…')
assert.equal(formatDuration(72), '1 min 12 s')
assert.equal(formatDuration(3705), '1 h 01 min')
job.status = 'paused'
job.speedBps = 50
assert.equal(currentTransferSpeed(job, 2100), 0)
})

View File

@ -1,40 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const src = path => readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8')
test('chaque import passe par le protocole fractionné avec progression en octets', () => {
const manager = src('hooks/useUploadQueue.js')
const api = src('api/client.js')
assert.match(manager, /onUploadProgress: event/)
assert.match(manager, /Math\.min\(part\.size, event\.loaded/)
assert.match(manager, /drive\.startUpload\(/)
assert.match(manager, /drive\.uploadStatus\(/)
assert.match(manager, /drive\.uploadChunk\(/)
assert.match(manager, /drive\.finishUpload\(/)
assert.match(manager, /drive\.cancelUpload\(/)
assert.match(manager, /requestRef\.current\?\.abort\(\)/)
assert.match(api, /cancelUpload:id=>api\.delete/)
})
test('un rafraichissement affiche un avertissement tant qu’un import reste en cours', () => {
const manager = src('hooks/useUploadQueue.js')
assert.match(manager, /addEventListener\('beforeunload'/)
assert.match(manager, /event\.preventDefault\(\)/)
assert.match(manager, /event\.returnValue = ''/)
})
test('les dossiers partagés sont actualisés silencieusement sans rechargement', () => {
const controller = src('hooks/useDriveController.js')
assert.match(controller, /setInterval\(poll, 4000\)/)
assert.match(controller, /document\.visibilityState/)
assert.match(controller, /refresh\(true\)/)
assert.match(controller, /requestSequence\.current/)
})
test('les longs titres et les boutons du menu ne débordent plus', () => {
const dialog = src('components/drive/dialogs/ItemActionsDialog.jsx')
assert.match(dialog, /break-all/)
assert.match(dialog, /grid-cols-1/)
assert.match(dialog, /min-w-0/)
})

View File

@ -1,75 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
// The live client uses Axios. This isolated test checks its request construction
// without needing a browser or an installed dependency tree.
const clientSource = await readFile(new URL('../src/api/client.js', import.meta.url), 'utf8')
const calls = []
const clients = []
globalThis.window ??= { location: { hostname: 'localhost' } }
globalThis.__goodDriveTestAxios = {
create(defaults) {
const instance = {
defaults,
interceptors: { request: { use() {} }, response: { use() {} } },
async post(url, body, config) {
calls.push({ url, body, config, defaults })
if (url.endsWith('/uploads/start/')) return { data: { id: 'test-upload-id', offset: 0 } }
if (url.endsWith('/chunk/')) {
return { data: { offset: Number(body.get('offset')) + body.get('part').size } }
}
return { data: { items: [] } }
},
}
clients.push(instance)
return instance
},
}
assert.match(clientSource, /import axios from ['"]axios['"]/, 'Unexpected Axios import')
const sourceWithMock = clientSource.replace(/import axios from ['"]axios['"]/, 'const axios = globalThis.__goodDriveTestAxios')
const { drive } = await import(`data:text/javascript;base64,${Buffer.from(sourceWithMock).toString('base64')}`)
function assertMultipartRequest(call) {
assert.ok(call.body instanceof FormData, `${call.url} must send FormData`)
assert.ok(
!Object.keys(call.defaults.headers ?? {}).some(key => key.toLowerCase() === 'content-type'),
'Do not force application/json globally; let Axios/browser set multipart boundary',
)
assert.ok(
!Object.keys(call.config?.headers ?? {}).some(key => key.toLowerCase() === 'content-type' && call.config.headers[key] === 'application/json'),
'Do not override multipart with application/json on this request',
)
}
test('small-file upload sends FormData, with no global JSON content type', async () => {
calls.length = 0
const file = new File(['abc'], 'demo.txt', { type: 'text/plain' })
await drive.upload([file], 12, 7)
const upload = calls.find(call => call.url === '/drive/uploads/')
assert.ok(upload)
assertMultipartRequest(upload)
assert.equal(upload.body.get('files').name, 'demo.txt')
assert.equal(upload.body.get('parent'), '12')
assert.equal(upload.body.get('space'), '7')
})
test('resumable upload sends JSON metadata and multipart binary chunks', async () => {
calls.length = 0
const file = new File([new Uint8Array(8 * 1024 * 1024 + 3)], 'video.bin')
await drive.uploadResumable(file, null, 7)
const start = calls.find(call => call.url === '/drive/uploads/start/')
const parts = calls.filter(call => call.url.endsWith('/chunk/'))
const finish = calls.find(call => call.url.endsWith('/finish/'))
assert.ok(start)
assert.equal(start.body.name, 'video.bin')
assert.equal(start.body.size, file.size)
assert.ok(!(start.body instanceof FormData))
assert.equal(parts.length, 2)
for (const part of parts) assertMultipartRequest(part)
assert.equal(parts[0].body.get('part').size, 8 * 1024 * 1024)
assert.equal(parts[1].body.get('part').size, 3)
assert.ok(finish)
})

29
start.sh Normal file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
cd /home/container/backend
# Lors du premier démarrage, laisser à la base le temps de devenir disponible.
tries=0
until python manage.py migrate --noinput; do
tries=$((tries+1))
if ((tries >= 30)); then
echo "Impossible d'appliquer les migrations après 30 tentatives." >&2
exit 1
fi
sleep 2
done
python manage.py ensure_admin
python manage.py runserver 0.0.0.0:1029 &
backend_pid=$!
cd /home/container/frontend
export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://127.0.0.1:1029}"
npm run dev -- --host 0.0.0.0 --port 1028 &
frontend_pid=$!
cleanup() {
kill "$backend_pid" "$frontend_pid" 2>/dev/null || true
wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
}
trap cleanup EXIT SIGINT SIGTERM
wait -n "$backend_pid" "$frontend_pid"