extend(TestCase::class) ->use(RefreshDatabase::class); beforeEach(function () { Storage::fake('shares'); $this->service = app(ShareService::class); }); test('create share without password stores encryption key', function () { $file = UploadedFile::fake()->create('document.pdf', 1024); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ]); expect($share)->toBeInstanceOf(Share::class); expect($share->token)->toHaveLength(16); expect($share->password)->toBeNull(); expect($share->encryption_key)->not->toBeNull(); expect($share->wrapped_key)->toBeNull(); expect($share->files)->toHaveCount(1); expect($share->files->first()->original_name)->toBe('document.pdf'); }); test('create share with password does not store encryption key', function () { $file = UploadedFile::fake()->create('secret.txt', 512); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ], [ 'password' => 'my-password', ]); expect($share->password)->not->toBeNull(); expect($share->encryption_key)->toBeNull(); expect(Hash::check('my-password', $share->password))->toBeTrue(); }); test('create share with options sets expiration and max downloads', function () { $file = UploadedFile::fake()->create('file.txt', 256); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ], [ 'expires_at' => now()->addDay(), 'max_downloads' => 5, ]); expect($share->expires_at)->not->toBeNull(); expect($share->max_downloads)->toBe(5); }); test('create share with multiple files', function () { $file1 = UploadedFile::fake()->create('file1.txt', 100); $file2 = UploadedFile::fake()->create('file2.txt', 200); $share = $this->service->createShare([ ['file' => $file1, 'relativePath' => 'folder/file1.txt'], ['file' => $file2, 'relativePath' => 'folder/file2.txt'], ]); expect($share->files)->toHaveCount(2); expect($share->files->first()->relative_path)->toBe('folder/file1.txt'); }); test('delete share removes files and database records', function () { $file = UploadedFile::fake()->create('file.txt', 100); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ]); $shareId = $share->id; $token = $share->token; $this->service->deleteShare($share); expect(Share::query()->find($shareId))->toBeNull(); expect(ShareFile::query()->where('share_id', $shareId)->count())->toBe(0); expect(Storage::disk('shares')->directories())->not->toContain($token); }); test('verify password returns true for correct password', function () { $file = UploadedFile::fake()->create('file.txt', 100); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ], [ 'password' => 'correct-password', ]); expect($this->service->verifyPassword($share, 'correct-password'))->toBeTrue(); expect($this->service->verifyPassword($share, 'wrong-password'))->toBeFalse(); }); test('verify password returns true for non-password share', function () { $file = UploadedFile::fake()->create('file.txt', 100); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ]); expect($this->service->verifyPassword($share, 'any'))->toBeTrue(); }); test('record download increments counter', function () { $share = Share::factory()->create(['download_count' => 0]); $this->service->recordDownload($share); expect($share->fresh()->download_count)->toBe(1); }); test('record download auto-deletes when limit reached', function () { $share = Share::factory()->withMaxDownloads(1)->create(['download_count' => 0]); $this->service->recordDownload($share); expect(Share::query()->find($share->id))->toBeNull(); }); test('get total used space sums share sizes', function () { Share::factory()->create(['total_size' => 1000]); Share::factory()->create(['total_size' => 2000]); expect($this->service->getTotalUsedSpace())->toBe(3000); }); test('is storage full checks against quota', function () { Setting::set('max_storage_quota', 1000); Share::factory()->create(['total_size' => 999]); expect($this->service->isStorageFull())->toBeFalse(); Share::factory()->create(['total_size' => 1]); expect($this->service->isStorageFull())->toBeTrue(); }); test('get decryption key returns stored key for non-password share', function () { $file = UploadedFile::fake()->create('file.txt', 100); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ]); $key = $this->service->getDecryptionKey($share); expect($key)->not->toBeNull(); expect(strlen($key))->toBe(64); }); test('get decryption key unwraps the data key of a password share, which decrypts its files', function () { $file = UploadedFile::fake()->createWithContent('file.txt', 'the secret contents'); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ], [ 'password' => 'test-password', ]); $key = $this->service->getDecryptionKey($share, 'test-password'); $decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($share->files->first()), $key), false)); expect($decrypted)->toBe('the secret contents'); }); test('get decryption key derives the key of a password share created before key wrapping', function () { $salt = str_repeat('ab', 32); $share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]); $key = $this->service->getDecryptionKey($share, 'old-password'); expect($key)->toBe(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 64)); }); test('get decryption key throws for password share without password', function () { $file = UploadedFile::fake()->create('file.txt', 100); $share = $this->service->createShare([ ['file' => $file, 'relativePath' => null], ], [ 'password' => 'test-password', ]); $this->service->getDecryptionKey($share); })->throws(RuntimeException::class, 'Password required'); test('registering a file starts a pending share with the file\'s encrypted header on disk', function () { config(['uploads.chunk_size' => 4]); $file = $this->service->registerFile(null, 'report.pdf', 10, 'reports/report.pdf'); expect($file->share->isCompleted())->toBeFalse(); expect($file->share->total_size)->toBe(10); expect($file->relative_path)->toBe('reports/report.pdf'); expect($file->completed_at)->toBeNull(); expect(file_get_contents($this->service->storedFilePath($file), false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 4)); }); test('a second registered file joins the same pending share and adds its size', function () { $first = $this->service->registerFile(null, 'one.txt', 10, null); $second = $this->service->registerFile($first->share, 'two.txt', 20, null); expect($second->share_id)->toBe($first->share_id); expect($second->share->total_size)->toBe(30); }); test('a file larger than the admin file size limit is rejected', function () { Setting::set('max_file_size', 5 * 1024 * 1024); expect(fn () => $this->service->registerFile(null, 'big.iso', 6 * 1024 * 1024, null)) ->toThrow(ValidationException::class, '"big.iso" is too large (6 MB). Maximum file size is 5 MB.'); expect(Share::query()->count())->toBe(0); }); test('a file beyond the admin limit of files per share is rejected', function () { Setting::set('max_files_per_share', 1); $first = $this->service->registerFile(null, 'one.txt', 10, null); expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null)) ->toThrow(ValidationException::class, 'Too many files. Maximum 1 files allowed per share.'); expect(ShareFile::query()->count())->toBe(1); }); test('a file that takes the share beyond the admin size per share is rejected', function () { Setting::set('max_size_per_share', 25); $first = $this->service->registerFile(null, 'one.txt', 20, null); expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null)) ->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.'); }); test('a file that does not fit the storage quota beside files still uploading is rejected', function () { Setting::set('max_storage_quota', 100); Share::factory()->pending()->create(['total_size' => 60]); expect(fn () => $this->service->registerFile(null, 'file.txt', 50, null)) ->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.'); }); test('chunks stored in order complete the file', function () { config(['uploads.chunk_size' => 4]); $file = $this->service->registerFile(null, 'notes.txt', 6, null); $afterFirst = $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, false)); $afterLast = $this->service->storeChunk($file->refresh(), 1, encryptedChunk($file, 'ef', 1, true)); expect([$afterFirst, $afterLast])->toBe([1, 2]); expect($file->refresh()->completed_at)->not->toBeNull(); $decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($file), $file->share->encryption_key), false)); expect($decrypted)->toBe('abcdef'); }); test('a chunk stored a second time is counted once', function () { config(['uploads.chunk_size' => 4]); $file = $this->service->registerFile(null, 'notes.txt', 6, null); $chunk = encryptedChunk($file, 'abcd', 0, false); $this->service->storeChunk($file, 0, $chunk); $uploadedChunks = $this->service->storeChunk($file, 0, $chunk); expect($uploadedChunks)->toBe(1); expect($file->refresh()->uploaded_chunks)->toBe(1); }); test('a chunk with the wrong length is rejected', function () { config(['uploads.chunk_size' => 4]); $file = $this->service->registerFile(null, 'notes.txt', 6, null); expect(fn () => $this->service->storeChunk($file, 0, encryptedChunk($file, 'abc', 0, false))) ->toThrow(InvalidArgumentException::class, 'wrong length'); expect($file->refresh()->uploaded_chunks)->toBe(0); }); test('a chunk that fails authentication is rejected', function () { config(['uploads.chunk_size' => 4]); $file = $this->service->registerFile(null, 'notes.txt', 6, null); $chunk = encryptedChunk($file, 'abcd', 0, false); $chunk[0] = $chunk[0] ^ "\x01"; expect(fn () => $this->service->storeChunk($file, 0, $chunk)) ->toThrow(InvalidArgumentException::class, 'failed authentication'); expect($file->refresh()->uploaded_chunks)->toBe(0); }); test('a chunk beyond the end of the file is rejected', function () { config(['uploads.chunk_size' => 4]); $file = $this->service->registerFile(null, 'notes.txt', 4, null); expect(fn () => $this->service->storeChunk($file, 1, encryptedChunk($file, 'abcd', 1, true))) ->toThrow(InvalidArgumentException::class, 'beyond the end'); }); test('the MIME type is detected from the first chunk\'s content', function () { $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='); $file = $this->service->registerFile(null, 'photo.bin', strlen($png), null); $this->service->storeChunk($file, 0, encryptedChunk($file, $png, 0, true)); expect($file->refresh()->mime_type)->toBe('image/png'); }); test('a chunk for a removed file is rejected', function () { $file = $this->service->registerFile(null, 'notes.txt', 4, null); $chunk = encryptedChunk($file, 'abcd', 0, true); $this->service->removeFile($file); $this->service->storeChunk($file, 0, $chunk); })->throws(ModelNotFoundException::class); test('removing a file deletes it and gives its size back', function () { $keep = $this->service->registerFile(null, 'keep.txt', 10, null); $remove = $this->service->registerFile($keep->share, 'remove.txt', 20, null); $path = $this->service->storedFilePath($remove); $this->service->removeFile($remove); expect($keep->share->refresh()->total_size)->toBe(10); expect(file_exists($path))->toBeFalse(); $this->assertModelMissing($remove); }); test('completing a share with a file still uploading is rejected', function () { $file = $this->service->registerFile(null, 'notes.txt', 4, null); expect(fn () => $this->service->completeShare($file->share)) ->toThrow(ValidationException::class, 'Wait until every file has finished uploading, or remove the ones that failed.'); expect($file->share->refresh()->isCompleted())->toBeFalse(); }); test('completing a share without files is rejected', function () { $share = Share::factory()->pending()->create(); expect(fn () => $this->service->completeShare($share)) ->toThrow(ValidationException::class, 'Please select at least one file to upload.'); }); test('completing a share with a password wraps its data key instead of storing it', function () { $file = $this->service->registerFile(null, 'notes.txt', 4, null); $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, true)); $dataKey = $file->share->encryption_key; $share = $this->service->completeShare($file->share, ['password' => 'a-long-password']); expect($share->refresh()->isCompleted())->toBeTrue(); expect($share->encryption_key)->toBeNull(); expect(app(FileEncryptionService::class)->unwrapKey($share->wrapped_key, 'a-long-password'))->toBe($dataKey); }); test('create share stores an empty file', function () { $share = $this->service->createShare([ ['file' => UploadedFile::fake()->createWithContent('empty.txt', ''), 'relativePath' => null], ]); expect($share->files->first()->completed_at)->not->toBeNull(); expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH); });