diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f01ac..c228ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Docker installs updated from 2.0 answered every upload with "409 Conflict". The example `docker-compose.yml` mounted the SQLite volume over all of `/app/database`, which hid the image's new migration, so it never ran. The container now adds the migrations the volume is missing before migrating, so existing compose files keep working. +- A share with several files and a download limit was deleted as soon as one file was downloaded: every single file counted as a whole download. Now one recipient's visit counts once, and they have 1 hour to download all the files and the ZIP. Two recipients who start at the same moment can no longer both get the last download. ### Changed - `docker-compose.example.yml` mounts `sealshare_database` at `/app/database/sqlite` and sets `DB_DATABASE` to the file in it. To switch an existing install, mount the same volume there and set `DB_DATABASE: /app/database/sqlite/database.sqlite` in both services; the database is kept. +- A share that reached its download limit is closed at once, but deleted by the hourly cleanup 24 hours after its last download instead of immediately, so downloads still running can finish. Until then its files still count towards the storage quota. +- The download page of a share with a download limit says how many downloads are left, or how long the recipient can still download. The admin dashboard shows downloads as "2 of 3 downloads", marks shares at their limit as "Download limit reached" and no longer counts them as active. ## [2.1.0] - 2026-09-16 diff --git a/app/Console/Commands/CleanupExpiredShares.php b/app/Console/Commands/CleanupExpiredShares.php index 07b8dc8..f2385cb 100644 --- a/app/Console/Commands/CleanupExpiredShares.php +++ b/app/Console/Commands/CleanupExpiredShares.php @@ -14,6 +14,12 @@ class CleanupExpiredShares extends Command */ private const ABANDONED_AFTER_HOURS = 4; + /** + * How long a share at its download limit is kept after its last download, so that downloads its + * last recipients started can finish: a ZIP opens each file only when it reaches it. + */ + private const DELETE_AFTER_LIMIT_HOURS = 24; + protected $signature = 'shares:cleanup'; protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads'; @@ -23,7 +29,14 @@ class CleanupExpiredShares extends Command $expiredShares = Share::query() ->where(function ($query): void { $query->where('expires_at', '<', now()) - ->orWhereRaw('max_downloads IS NOT NULL AND download_count >= max_downloads'); + ->orWhere(function ($query): void { + $query->whereNotNull('max_downloads') + ->whereColumn('download_count', '>=', 'max_downloads') + ->where(function ($query): void { + $query->whereNull('last_downloaded_at') + ->orWhere('last_downloaded_at', '<', now()->subHours(self::DELETE_AFTER_LIMIT_HOURS)); + }); + }); }) ->get(); diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php index b768ab5..1be3f23 100644 --- a/app/Http/Controllers/DownloadController.php +++ b/app/Http/Controllers/DownloadController.php @@ -7,6 +7,7 @@ use App\Models\ShareFile; use App\Services\FileEncryptionService; use App\Services\ShareService; use GuzzleHttp\Psr7\PumpStream; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -24,13 +25,16 @@ class DownloadController extends Controller * Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without * compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk. */ - public function download(Share $share): StreamedResponse + public function download(Request $request, Share $share): StreamedResponse { - abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404); + abort_if(! $share->isCompleted() || $share->isExpired(), 404); $share->load('files'); $key = $this->resolveDecryptionKey($share); + // Counted before the body streams: the session is saved by then. + abort_unless($this->shareService->claimDownload($share, $request->session()), 404); + return new StreamedResponse(function () use ($share, $key): void { $zip = new ZipStream( defaultCompressionMethod: CompressionMethod::STORE, @@ -62,8 +66,6 @@ class DownloadController extends Controller } $zip->finish(); - - $this->shareService->recordDownload($share); }, 200, [ 'Content-Type' => 'application/zip', 'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'), @@ -73,13 +75,15 @@ class DownloadController extends Controller /** * Download a single file. */ - public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse + public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse { - abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404); + abort_if(! $share->isCompleted() || $share->isExpired(), 404); abort_if($shareFile->share_id !== $share->id, 404); $key = $this->resolveDecryptionKey($share); + abort_unless($this->shareService->claimDownload($share, $request->session()), 404); + $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path)); $mimeType = $shareFile->mime_type ?? 'application/octet-stream'; @@ -96,10 +100,8 @@ class DownloadController extends Controller $headers['Content-Length'] = $shareFile->file_size; } - return new StreamedResponse(function () use ($encryptedPath, $key, $share): void { + return new StreamedResponse(function () use ($encryptedPath, $key): void { $this->encryptionService->streamDecryptedFile($encryptedPath, $key); - - $this->shareService->recordDownload($share); }, 200, $headers); } diff --git a/app/Livewire/Admin/AdminDashboard.php b/app/Livewire/Admin/AdminDashboard.php index 35fd92f..af0e804 100644 --- a/app/Livewire/Admin/AdminDashboard.php +++ b/app/Livewire/Admin/AdminDashboard.php @@ -71,6 +71,8 @@ class AdminDashboard extends Component 'totalShares' => Share::query()->whereNotNull('completed_at')->count(), 'activeShares' => Share::query()->whereNotNull('completed_at')->where(function ($q) { $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + })->where(function ($q) { + $q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads'); })->count(), 'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(), 'usedSpace' => $shareService->getTotalUsedSpace(), diff --git a/app/Livewire/ShareDownload.php b/app/Livewire/ShareDownload.php index 467853a..90076f7 100644 --- a/app/Livewire/ShareDownload.php +++ b/app/Livewire/ShareDownload.php @@ -4,6 +4,7 @@ namespace App\Livewire; use App\Models\Share; use App\Services\ShareService; +use Carbon\CarbonInterval; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; @@ -19,11 +20,13 @@ class ShareDownload extends Component #[Validate('required|string')] public string $password = ''; - public function mount(Share $share): void + public function mount(Share $share, ShareService $shareService): void { $this->share = $share->load('files'); - if (! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit()) { + // A share at its download limit stays open for the recipient who took its last download. + if (! $share->isCompleted() || $share->isExpired() + || ($share->hasReachedDownloadLimit() && $shareService->downloadWindowEndsAt($share, session()->driver()) === null)) { abort(404); } @@ -65,6 +68,12 @@ class ShareDownload extends Component public function render(): mixed { - return view('livewire.share-download'); + $shareService = app(ShareService::class); + + return view('livewire.share-download', [ + 'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()), + 'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null, + 'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(), + ]); } } diff --git a/app/Models/Share.php b/app/Models/Share.php index c9545bf..2060f76 100644 --- a/app/Models/Share.php +++ b/app/Models/Share.php @@ -32,6 +32,7 @@ class Share extends Model 'expires_at' => 'datetime', 'max_downloads' => 'integer', 'download_count' => 'integer', + 'last_downloaded_at' => 'datetime', 'total_size' => 'integer', 'encryption_key' => 'encrypted', 'completed_at' => 'datetime', diff --git a/app/Services/ShareService.php b/app/Services/ShareService.php index f347a20..1c3e790 100644 --- a/app/Services/ShareService.php +++ b/app/Services/ShareService.php @@ -5,8 +5,11 @@ namespace App\Services; use App\Models\Setting; use App\Models\Share; use App\Models\ShareFile; +use Carbon\CarbonInterface; +use Illuminate\Contracts\Session\Session; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -21,6 +24,11 @@ use RuntimeException; */ class ShareService { + /** + * How long a recipient may keep starting downloads of a share after their download was counted. + */ + public const DOWNLOAD_WINDOW_MINUTES = 60; + public function __construct( private FileEncryptionService $encryptionService, ) {} @@ -318,15 +326,55 @@ class ShareService } /** - * Record a download and auto-delete if limit reached. + * When this session's download window for a share ends, or null while it has none open. The + * window opens with the session's counted download; until it ends, the session may start more + * downloads of the share without counting them, even once the share has reached its limit. */ - public function recordDownload(Share $share): void + public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface { - $share->increment('download_count'); + $countedAt = $session->get($this->downloadSessionKey($share)); - if ($share->hasReachedDownloadLimit()) { - $this->deleteShare($share); + if (! is_int($countedAt)) { + return null; } + + $endsAt = Carbon::createFromTimestamp($countedAt)->addMinutes(self::DOWNLOAD_WINDOW_MINUTES); + + return $endsAt->isFuture() ? $endsAt : null; + } + + /** + * Let this session download from a share: one recipient's visit is one download, so a session + * without an open window counts one and opens its window. The limit is checked in the same + * update that counts, so two recipients who start at once cannot both take the last download. + * False when no download is left for this session. + */ + public function claimDownload(Share $share, Session $session): bool + { + if ($this->downloadWindowEndsAt($share, $session) !== null) { + return true; + } + + $counted = Share::query() + ->whereKey($share->id) + ->where(fn ($query) => $query->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads')) + ->increment('download_count', 1, ['last_downloaded_at' => now()]); + + if ($counted === 0) { + return false; + } + + $session->put($this->downloadSessionKey($share), now()->getTimestamp()); + + return true; + } + + /** + * The session key holding when this session's download of a share was counted. + */ + private function downloadSessionKey(Share $share): string + { + return 'share_download_'.$share->token; } /** diff --git a/database/migrations/2026_09_17_131955_add_last_downloaded_at_to_shares_table.php b/database/migrations/2026_09_17_131955_add_last_downloaded_at_to_shares_table.php new file mode 100644 index 0000000..46289f5 --- /dev/null +++ b/database/migrations/2026_09_17_131955_add_last_downloaded_at_to_shares_table.php @@ -0,0 +1,31 @@ +timestamp('last_downloaded_at')->nullable()->after('download_count'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('shares', function (Blueprint $table) { + $table->dropColumn('last_downloaded_at'); + }); + } +}; diff --git a/resources/views/livewire/admin/admin-dashboard.blade.php b/resources/views/livewire/admin/admin-dashboard.blade.php index b1922c5..62b9e7b 100644 --- a/resources/views/livewire/admin/admin-dashboard.blade.php +++ b/resources/views/livewire/admin/admin-dashboard.blade.php @@ -39,9 +39,12 @@ {{ $share->token }} - {{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ trans_choice(':count download|:count downloads', $share->download_count) }} + {{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }} - @if (! $share->expires_at) + {{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}} + @if ($share->hasReachedDownloadLimit()) + {{ __('Download limit reached') }} + @elseif (! $share->expires_at) {{ __('Never expires') }} @elseif ($share->isExpired()) {{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }} diff --git a/resources/views/livewire/share-download.blade.php b/resources/views/livewire/share-download.blade.php index 307ec32..4cdcc66 100644 --- a/resources/views/livewire/share-download.blade.php +++ b/resources/views/livewire/share-download.blade.php @@ -23,7 +23,9 @@ @else - + {{-- A download link does not render the page again: the download limit's note switches + on the first press here, and the server draws the open window on the next visit. --}} + @foreach ($share->files as $file) @@ -34,7 +36,7 @@ > {{ Number::fileSize($file->file_size) }} - + @endforeach @@ -43,13 +45,22 @@ @if ($share->expires_at)

{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}

@endif + + @if ($share->max_downloads) + @if ($downloadWindowEndsAt) +

{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}

+ @elseif ($remainingDownloads > 0) +

{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}

+

{{ __('You have :window to download the files.', ['window' => $downloadWindow]) }}

+ @endif + @endif
@if ($share->files->count() > 1) - + @else - + @endif
diff --git a/tests/Browser/SealShareTest.php b/tests/Browser/SealShareTest.php index ff0a9d1..c3436e4 100644 --- a/tests/Browser/SealShareTest.php +++ b/tests/Browser/SealShareTest.php @@ -172,6 +172,25 @@ test('a recipient on a phone unlocks a password-protected share and sees its fil ->assertScript('document.documentElement.scrollWidth <= window.innerWidth'); }); +test('pressing a download on a limited share turns the note about remaining downloads into the recipient\'s hour', function () { + $share = app(ShareService::class)->createShare( + [['file' => UploadedFile::fake()->createWithContent('report.pdf', 'report'), 'relativePath' => null]], + ['max_downloads' => 3], + ); + + $page = ready(visit(route('share.download', $share, false))); + + $page->assertSee('Downloading uses 1 of 3 remaining downloads.') + ->assertDontSee('You have 1 hour to download the files.'); + + // The press is what the page reacts to; the download itself stays out of the browser. + $page->script("window.eval(\"document.addEventListener('click', (event) => event.preventDefault(), true); document.querySelector('[aria-label=\\\"Download report.pdf\\\"]').click()\")"); + + $page->assertSee('You have 1 hour to download the files.') + ->assertDontSee('Downloading uses 1 of 3 remaining downloads.') + ->assertNoJavaScriptErrors(); +}); + test('an admin sorts the shares list and deletes a share through its dialog', function () { $admin = User::factory()->admin()->create(); Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]); diff --git a/tests/Feature/Admin/AdminDashboardTest.php b/tests/Feature/Admin/AdminDashboardTest.php index 300dbc2..ae19b7f 100644 --- a/tests/Feature/Admin/AdminDashboardTest.php +++ b/tests/Feature/Admin/AdminDashboardTest.php @@ -73,6 +73,20 @@ test('admin dashboard lists the shares', function () { $response->assertSee('href="'.route('share.download', $share).'"', false); }); +test('the shares list shows downloads against the limit, and a share at its limit as closed and not active', function () { + $admin = User::query()->where('is_admin', true)->first(); + Share::factory()->withMaxDownloads(3)->create(['token' => 'limitedshare0000', 'download_count' => 2, 'expires_at' => null]); + Share::factory()->withMaxDownloads(1)->create(['token' => 'limitreached0000', 'download_count' => 1, 'expires_at' => null]); + Share::factory()->create(['token' => 'unlimitedshare00', 'download_count' => 1, 'expires_at' => null]); + + $dashboard = Livewire::actingAs($admin)->test(AdminDashboard::class); + + $dashboard->assertSeeInOrder(['limitedshare0000', '2 of 3 downloads', 'Never expires']) + ->assertSeeInOrder(['limitreached0000', '1 of 1 download', 'Download limit reached']) + ->assertSeeInOrder(['unlimitedshare00', '1 download', 'Never expires']) + ->assertViewHas('activeShares', 2); +}); + test('the shares list sorts only by its own orders', function () { $admin = User::query()->where('is_admin', true)->first(); diff --git a/tests/Feature/CleanupExpiredSharesTest.php b/tests/Feature/CleanupExpiredSharesTest.php index 9a53fec..2304cd6 100644 --- a/tests/Feature/CleanupExpiredSharesTest.php +++ b/tests/Feature/CleanupExpiredSharesTest.php @@ -34,6 +34,19 @@ test('cleanup removes shares that reached download limit', function () { expect(Share::query()->find($underLimit->id))->not->toBeNull(); }); +test('cleanup keeps a share at its download limit for a day after its last download', function () { + Storage::fake('shares'); + $recentlyDownloaded = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1, 'last_downloaded_at' => now()->subHours(23)]); + $downloadedYesterday = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1, 'last_downloaded_at' => now()->subHours(25)]); + + $this->artisan('shares:cleanup') + ->expectsOutputToContain('Cleaned up 1 expired share(s)') + ->assertExitCode(0); + + $this->assertModelExists($recentlyDownloaded); + $this->assertModelMissing($downloadedYesterday); +}); + test('cleanup handles no expired shares', function () { Storage::fake('shares'); diff --git a/tests/Feature/ShareDownloadTest.php b/tests/Feature/ShareDownloadTest.php index ffc2ad3..a6f8be8 100644 --- a/tests/Feature/ShareDownloadTest.php +++ b/tests/Feature/ShareDownloadTest.php @@ -112,15 +112,149 @@ test('zip download streams a valid archive with every file\'s original content', unlink($zipPath); }); -test('last download streams successfully before auto-delete', function () { +test('the last download streams and the share stays for the cleanup, with the time of that download', function () { Storage::fake('shares'); + $this->freezeSecond(); $share = createShareWithFile(); $share->update(['max_downloads' => 1]); $content = $this->get(route('share.download.all', $share))->streamedContent(); expect($content)->toStartWith("PK\x03\x04"); - $this->assertModelMissing($share); + expect($share->fresh()->last_downloaded_at)->toEqual(now()); +}); + +test('a recipient downloads every file and the zip of a share limited to one download, which counts once', function () { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: 1); + [$first, $second, $third] = $share->files->all(); + + $files = [ + $this->get(route('share.download.file', [$share, $first]))->streamedContent(), + $this->get(route('share.download.file', [$share, $second]))->streamedContent(), + $this->get(route('share.download.file', [$share, $third]))->streamedContent(), + ]; + $zip = $this->get(route('share.download.all', $share))->streamedContent(); + + expect($files)->toBe(['first file', 'second file', 'third file']); + expect($zip)->toStartWith("PK\x03\x04"); + expect($share->fresh()->download_count)->toBe(1); +}); + +test('another recipient cannot open a share whose last download was taken', function (string $route) { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: 1); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->flushSession(); + + $response = $this->get(route($route, ['share' => $share, 'shareFile' => $share->files->last()])); + + $response->assertNotFound(); +})->with([ + 'download page' => 'share.download', + 'download all' => 'share.download.all', + 'download one file' => 'share.download.file', +]); + +test('the recipient who took the last download can no longer open the share an hour later', function (string $route) { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: 1); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->travel(61)->minutes(); + + $response = $this->get(route($route, ['share' => $share, 'shareFile' => $share->files->last()])); + + $response->assertNotFound(); +})->with([ + 'download page' => 'share.download', + 'download all' => 'share.download.all', + 'download one file' => 'share.download.file', +]); + +test('a recipient whose window has ended uses another download when one is left', function () { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: 2); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->travel(61)->minutes(); + + $response = $this->get(route('share.download.file', [$share, $share->files->last()])); + + $response->assertOk(); + expect($share->fresh()->download_count)->toBe(2); +}); + +test('a recipient is refused the last download once another recipient has taken it', function () { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: 2); + $share->update(['download_count' => 1]); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->flushSession(); + + $response = $this->get(route('share.download.file', [$share, $share->files->first()])); + + $response->assertNotFound(); + expect($share->fresh()->download_count)->toBe(2); +}); + +test('a download refused before it starts uses no download', function (?string $password, Closure $file, string $assertion) { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: 1, password: $password); + $otherShare = createShareWithFiles(); + + $response = $this->get(route('share.download.file', [$share, $file($share, $otherShare)])); + + $response->{$assertion}(); + expect($share->fresh()->download_count)->toBe(0); +})->with([ + 'password share without its key' => ['secret-pass', fn (Share $share) => $share->files->first(), 'assertForbidden'], + 'file of another share' => [null, fn (Share $share, Share $otherShare) => $otherShare->files->first(), 'assertNotFound'], +]); + +test('a share without a limit counts each recipient once', function () { + Storage::fake('shares'); + $share = createShareWithFiles(); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->get(route('share.download.file', [$share, $share->files->last()])); + $this->flushSession(); + + $this->get(route('share.download.file', [$share, $share->files->first()])); + + expect($share->fresh()->download_count)->toBe(2); +}); + +test('the download page tells a recipient how many downloads are left', function (int $maxDownloads, string $note) { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: $maxDownloads); + + $response = $this->get(route('share.download', $share)); + + $response->assertSee($note); +})->with([ + 'several left' => [3, 'Downloading uses 1 of 3 remaining downloads. You then have 1 hour to download the files.'], + 'one left' => [1, 'Downloading uses the last remaining download. You then have 1 hour to download the files.'], +]); + +test('the download page tells the recipient who took the last download how long they have left', function () { + Storage::fake('shares'); + $this->freezeSecond(); + $share = createShareWithFiles(maxDownloads: 1); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->travel(2)->minutes(); + + $response = $this->get(route('share.download', $share)); + + $response->assertSee('You can download these files for another 58 minutes.'); + $response->assertDontSee('remaining download'); +}); + +test('the download page says nothing about downloads on a share without a limit', function () { + Storage::fake('shares'); + $share = createShareWithFiles(); + + $response = $this->get(route('share.download', $share)); + + $response->assertDontSee('to download the files'); + $response->assertDontSee('You can download these files'); }); test('a share whose files are still uploading is not found anywhere a recipient or uploader could open it', function (string $route) { @@ -157,23 +291,6 @@ test('a password share created before key wrapping still unlocks and downloads', expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content'); }); -test('share auto-deletes after reaching download limit', function () { - Storage::fake('shares'); - - $service = app(ShareService::class); - $file = UploadedFile::fake()->create('file.txt', 100); - - $share = $service->createShare([ - ['file' => $file, 'relativePath' => null], - ], [ - 'max_downloads' => 1, - ]); - - $service->recordDownload($share); - - expect(Share::query()->find($share->id))->toBeNull(); -}); - /** * Helper to create a share with an actual encrypted file. */ @@ -185,3 +302,18 @@ function createShareWithFile(?string $password = null, string $content = 'test c 'password' => $password, ]); } + +/** + * Helper to create a share with three encrypted files: "first file", "second file" and "third file". + */ +function createShareWithFiles(?int $maxDownloads = null, ?string $password = null): Share +{ + return app(ShareService::class)->createShare([ + ['file' => UploadedFile::fake()->createWithContent('first.txt', 'first file'), 'relativePath' => null], + ['file' => UploadedFile::fake()->createWithContent('second.txt', 'second file'), 'relativePath' => null], + ['file' => UploadedFile::fake()->createWithContent('third.txt', 'third file'), 'relativePath' => null], + ], [ + 'password' => $password, + 'max_downloads' => $maxDownloads, + ]); +} diff --git a/tests/Unit/ShareServiceTest.php b/tests/Unit/ShareServiceTest.php index f0d41c3..86ee3ad 100644 --- a/tests/Unit/ShareServiceTest.php +++ b/tests/Unit/ShareServiceTest.php @@ -118,22 +118,6 @@ test('verify password returns true for non-password share', function () { 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]);