get(route('share.download', $share)); $response->assertOk(); }); test('share download page returns 404 for expired share', function () { $share = Share::factory()->expired()->create(); $response = $this->get(route('share.download', $share)); $response->assertNotFound(); }); test('share download page returns 404 when download limit reached', function () { $share = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1]); $response = $this->get(route('share.download', $share)); $response->assertNotFound(); }); test('share download page shows password form for password-protected share', function () { Storage::fake('shares'); $share = createShareWithFile('secret-pass'); $response = $this->get(route('share.download', $share)); $response->assertOk(); $response->assertSee('password'); }); test('password verification works for protected share', function () { Storage::fake('shares'); $share = createShareWithFile('my-password'); Livewire::test(ShareDownload::class, ['share' => $share]) ->assertSet('authenticated', false) ->set('password', 'my-password') ->call('verifyPassword') ->assertSet('authenticated', true) ->assertHasNoErrors(); }); test('wrong password is rejected', function () { Storage::fake('shares'); $share = createShareWithFile('my-password'); Livewire::test(ShareDownload::class, ['share' => $share]) ->set('password', 'wrong-password') ->call('verifyPassword') ->assertSet('authenticated', false) ->assertHasErrors(['password']); }); test('non-password share shows files directly', function () { Storage::fake('shares'); $share = createShareWithFile(); Livewire::test(ShareDownload::class, ['share' => $share]) ->assertSet('authenticated', true); }); test('download counter increments on zip download', function () { Storage::fake('shares'); $share = createShareWithFile(); $response = $this->get(route('share.download.all', $share)); $response->streamedContent(); $response->assertDownload('share-'.$share->token.'.zip'); expect($share->fresh()->download_count)->toBe(1); }); test('zip download streams a valid archive with every file\'s original content', function () { Storage::fake('shares'); config(['uploads.chunk_size' => 1000]); $binary = random_bytes(2500); $share = app(ShareService::class)->createShare([ ['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello zip content'), 'relativePath' => null], ['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'], ]); $zipPath = tempnam(sys_get_temp_dir(), 'zip'); file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent()); $zip = new ZipArchive; expect($zip->open($zipPath))->toBeTrue(); expect($zip->numFiles)->toBe(2); expect($zip->getFromName('notes.txt'))->toBe('hello zip content'); expect($zip->getFromName('holiday/photo.bin'))->toBe($binary); $zip->close(); unlink($zipPath); }); 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"); 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) { Storage::fake('shares'); $share = Share::factory()->pending()->create(); $file = ShareFile::factory()->for($share)->uploading()->create(); $response = $this->get(route($route, ['share' => $share, 'shareFile' => $file])); $response->assertNotFound(); })->with([ 'download page' => 'share.download', 'download all' => 'share.download.all', 'download one file' => 'share.download.file', 'share created page' => 'share.created', ]); test('a password share created before key wrapping still unlocks and downloads', function () { Storage::fake('shares'); $salt = str_repeat('cd', 32); $share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]); $file = ShareFile::factory()->for($share)->create(['stored_path' => 'shares/'.$share->token.'/old.enc', 'file_size' => 11]); $source = tempnam(sys_get_temp_dir(), 'old'); file_put_contents($source, 'old content'); Storage::disk('shares')->makeDirectory($share->token); app(FileEncryptionService::class)->encryptFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024); unlink($source); Livewire::test(ShareDownload::class, ['share' => $share]) ->set('password', 'old-password') ->call('verifyPassword') ->assertSet('authenticated', true); expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content'); }); /** * Helper to create a share with an actual encrypted file. */ function createShareWithFile(?string $password = null, string $content = 'test content'): Share { return app(ShareService::class)->createShare([ ['file' => UploadedFile::fake()->createWithContent('testfile.txt', $content), 'relativePath' => null], ], [ '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, ]); }