Files
SealShare/tests/Unit/ShareServiceTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 e057cada3d Count a share's download limit per recipient, not per file
With a download limit of 1, downloading one file of a share with
several files deleted the share and the files not yet downloaded.
ShareService::recordDownload() ran at the end of every download
request, one file or the ZIP alike, and deleted the share as soon as
download_count reached max_downloads. It has worked that way since
the first commit.

- One recipient's visit is one download. The first file or ZIP a
  session downloads is counted when it starts, in one conditional
  UPDATE that also checks the limit, so two recipients starting at
  once can't both take the last download. The session remembers the
  time, and for ShareService::DOWNLOAD_WINDOW_MINUTES (60) it may
  start more downloads of the share without counting them, even once
  the limit is reached. The claim happens in the controller before
  streaming, because the session is saved before the body is sent,
  and after the share key is resolved, so a request without the key
  uses nothing.
- A share at its limit is closed to everyone else at once. The hourly
  cleanup deletes it 24 hours after shares.last_downloaded_at (new
  column), since a ZIP opens each file only when it reaches it and a
  large download can outlast the hour.
- The download page of a limited share says how many downloads are
  left, switches to "You have 1 hour" on the first press (Alpine, as
  a download link does not render the page again), and shows the time
  left on the next visit.
- The admin dashboard shows "2 of 3 downloads", marks shares at their
  limit "Download limit reached" and leaves them out of Active Shares.
- Tests: the regression (3 files, limit 1: every file and the ZIP
  download, counted once), another recipient, the end of the hour,
  the last download going to one of two recipients, requests refused
  before streaming, unlimited shares, the page notes in PHP and in
  Chromium, the dashboard, and the cleanup at 23 and 25 hours. The
  tests of recordDownload() and of the instant deletion are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:35:13 +02:00

358 lines
14 KiB
PHP

<?php
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
pest()->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('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);
});