withoutVite(); Storage::fake('ssh-keys'); InstanceSettings::forceCreate(['id' => 0]); config(['constants.ssh.mux_enabled' => false]); $this->team = Team::factory()->create(); $this->admin = User::factory()->create(); $this->admin->teams()->attach($this->team, ['role' => 'admin']); $this->member = User::factory()->create(); $this->member->teams()->attach($this->team, ['role' => 'member']); $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $this->server = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $privateKey->id, ]); $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); StandaloneDocker::withoutEvents(function () { $this->destination = StandaloneDocker::firstOrCreate( ['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] ); }); $this->project = Project::create([ 'uuid' => (string) Str::uuid(), 'name' => 'Test Project', 'team_id' => $this->team->id, ]); $this->environment = $this->project->environments()->first(); }); it('blocks non-admin members from the file browser', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $this->actingAs($this->member); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $application]) ->assertForbidden(); }); it('shows a stopped state when the container is not running', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'status' => 'exited', ]); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $application]) ->assertSet('containerRunning', false) ->assertSee('Start the container to browse its files'); }); it('loads the default directory in a deferred request for a running database', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/var/lib/postgresql')) // defaultRoot inspect ->push(Process::result(output: "dir\t0\t1\tdata\nfile\t10\t2\tpostgresql.conf"))]); // list $database = StandalonePostgresql::create([ 'uuid' => (string) Str::uuid(), 'name' => 'Test DB', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'testdb', 'image' => 'postgres:15', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->assertSet('containerRunning', true) ->assertSet('currentPath', '/var/lib/postgresql') ->assertSet('container', $database->uuid) ->assertSee('data') ->assertSee('postgresql.conf'); }); function fbRunningDatabase($environment, $destination): StandalonePostgresql { return StandalonePostgresql::create([ 'uuid' => (string) Str::uuid(), 'name' => 'DB', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'testdb', 'image' => 'postgres:15', 'status' => 'running', 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]); } function fbNoShellResult(): ProcessResult { return Process::result( errorOutput: 'OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH', exitCode: 127, ); } function fbRootProcessStatus(): string { return "Uid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nGroups:\t0\nCapPrm:\t00000000a80425fb\n"; } it('browses a container without a shell through the helper container', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/')) // defaultRoot inspect ->push(fbNoShellResult()) // docker exec list ->push(Process::result(output: fbRootProcessStatus())) // helper identity ->push(Process::result(output: "directory\t0\t1\t755\troot\troot\t./data\nsymbolic link\t4\t1\t777\troot\troot\t./etc-link"))]); // helper list $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->assertSet('useHelper', true) ->assertSet('shellUnavailable', false) ->assertSee('data') ->assertSee('Upload a file to this folder') ->call('open', 'etc-link') ->assertDispatched('error', 'Symlinks cannot be opened in a container without a shell. Open the target path instead.') ->assertSet('currentPath', '/'); }); it('explains when neither the shell nor the helper container can open the files', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/')) ->push(fbNoShellResult()) ->push(Process::result(output: fbRootProcessStatus())) ->push(Process::result(errorOutput: 'Unable to find image', exitCode: 125))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->assertSet('loadFailed', false) ->assertSet('shellUnavailable', true) ->assertSee('Coolify cannot open the files of this container.') ->assertSee('The server could not pull the Coolify helper image') ->assertDontSee('This folder is empty') ->assertDontSee('Upload a file to this folder'); }); it('keeps helper mode in the download token', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/')) ->push(fbNoShellResult()) ->push(Process::result(output: fbRootProcessStatus())) ->push(Process::result(output: "directory\t0\t1\t755\troot\troot\t./data")) ->push(Process::result(output: fbRootProcessStatus())) ->push(Process::result(output: 'dir 4'))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('download', 'data'); $token = Str::afterLast($component->effects['redirect'], '/'); expect(Cache::get(FileBrowserDownloadController::cacheKey($token)))->toMatchArray(['use_helper' => true, 'path' => '/data']); }); it('creates a directory and re-lists', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot ->push(Process::result(output: '')) // initial list (empty) ->push(Process::result(output: '')) // mkdir ->push(Process::result(output: "dir\t0\t1\tplugins"))]); // re-list $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('createDirectory', 'plugins') ->assertSee('plugins'); }); it('creates a file and re-lists', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot ->push(Process::result(output: '')) // initial list (empty) ->push(Process::result(output: '')) // createFile ->push(Process::result(output: "file\t0\t1\t644\tconfig.yml"))]); // re-list $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('createFile', 'config.yml') ->assertSee('config.yml'); }); it('changes permissions and re-lists', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot ->push(Process::result(output: "file\t10\t1\t644\tapp.sh")) // initial list ->push(Process::result(output: '')) // chmod ->push(Process::result(output: "file\t10\t1\t755\tapp.sh"))]); // re-list $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->assertSee('644') ->call('chmodEntry', 'app.sh', '755') ->assertSee('755'); }); it('sets the editor language and content from the file when opening', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot ->push(Process::result(output: "file\t12\t1\t644\tconfig.yml")) // initial list ->push(Process::result(output: "OK\n".base64_encode("a: 1\n")))]); // single-round-trip read $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('openEditor', 'config.yml') ->assertSet('editorOpen', true) ->assertSet('editorLanguage', 'yaml') ->assertSet('editorContent', "a: 1\n") ->assertDispatched('load-file-editor', content: "a: 1\n", language: 'yaml'); }); it('resolves a Monaco language for common extension-less files', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot ->push(Process::result(output: "file\t12\t1\t644\tDockerfile")) // initial list ->push(Process::result(output: "OK\n".base64_encode("FROM alpine\n")))]); // read $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('openEditor', 'Dockerfile') ->assertSet('editorLanguage', 'dockerfile'); }); it('refuses to open a binary or oversized file in the editor', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot ->push(Process::result(output: "file\t99999999\t1\tbig.bin")) // initial list ->push(Process::result(output: 'TOOBIG'))]); // single-round-trip read $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('openEditor', 'big.bin') ->assertSet('editingPath', null); }); it('rejects client changes to container access state', function (string $property, mixed $value) { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) // defaultRoot ->push(Process::result(output: ''))]); // initial list $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->set($property, $value); })->with([ ['container', 'someone-elses-container'], ['type', 'service'], ['containerRunning', false], ['availableContainers', ['someone-elses-container']], ])->throws(CannotUpdateLockedPropertyException::class); it('rechecks resource access before file operations', function (string $action, array $arguments) { Process::fake(['*' => Process::result(output: '')]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); $otherTeam = Team::factory()->create(); $this->project->update(['team_id' => $otherTeam->id]); $component->call($action, ...$arguments)->assertForbidden(); })->with([ ['open', ['folder']], ['goTo', ['/data']], ['refresh', []], ['loadFiles', []], ['selectContainer', ['unknown']], ['saveEditor', []], ['deleteEntry', ['config.yml']], ['download', ['config.yml']], ]); it('rejects a client attempt to replace the editor path', function () { Process::fake(['*' => Process::result(output: '')]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->set('editingPath', '/etc/passwd'); })->throws(CannotUpdateLockedPropertyException::class); it('rejects unsafe child names without deleting the current folder', function (string $name) { Process::fake(['*' => Process::result(output: '')]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('deleteEntry', $name) ->assertDispatched('error'); Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -rf')); })->with(['', '.', '..', '../data', 'a/b', 'a\\b']); it('saves the opened file and clears editor state', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: "OK\n".base64_encode('before'))) ->push(Process::result(output: '')) ->push(Process::result(output: ''))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('openEditor', "it's.txt") ->set('editorContent', 'after') ->call('saveEditor') ->assertSet('editingPath', null) ->assertSet('editorOpen', false) ->assertSet('editorContent', '') ->assertDispatched('success', 'Saved.'); }); it('clears the editor when selecting a container', function () { Process::fake(['*' => Process::result(output: 'OK')]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('openEditor', 'config.yml') ->assertSet('editorOpen', true) ->call('selectContainer', $database->uuid) ->assertSet('editingPath', null) ->assertSet('editorOpen', false); }); it('renames and deletes files with apostrophes', function () { Process::fake(['*' => Process::result(output: '')]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('renameEntry', "it's.txt", "that's.txt") ->assertDispatched('success', 'Renamed.') ->call('deleteEntry', "that's.txt") ->assertDispatched('success', 'Deleted.'); }); it('uploads a file and clears the upload state', function () { Process::fake(['*' => Process::result(output: '')]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->set('upload', UploadedFile::fake()->create("it's.txt", 1, 'text/plain')) ->call('uploadFile') ->assertSet('upload', null) ->assertDispatched('success', 'Uploaded.'); }); it('issues a one-time download token and redirects to the streaming route', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: 'dir 4'))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('download', 'folder') ->assertRedirectContains('/files/download/'); $token = Str::afterLast($component->effects['redirect'], '/'); expect(Cache::get(FileBrowserDownloadController::cacheKey($token)))->toMatchArray([ 'user_id' => $this->admin->id, 'container' => $database->uuid, 'path' => '/data/folder', 'name' => 'folder.tar.gz', 'is_dir' => true, ]); }); it('refuses downloads above the size cap', function () { $overCapKb = intdiv(ContainerFilesystemService::MAX_DOWNLOAD_SIZE, 1024) + 1; Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: "dir {$overCapKb}"))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('download', 'folder') ->assertNoRedirect() ->assertDispatched('error'); }); it('only lets the token owner use a download token, and only once', function () { $database = fbRunningDatabase($this->environment, $this->destination); $download = [ 'user_id' => $this->admin->id, 'resource_type' => $database::class, 'resource_id' => $database->id, 'server_id' => $this->server->id, 'container' => $database->uuid, 'path' => '/data/a.txt', 'name' => 'a.txt', 'is_dir' => false, ]; $otherAdmin = User::factory()->create(); $otherAdmin->teams()->attach($this->team, ['role' => 'admin']); Cache::put(FileBrowserDownloadController::cacheKey('token-a'), $download, 60); $this->actingAs($otherAdmin)->withSession(['currentTeam' => $this->team]) ->get(route('project.files.download', 'token-a')) ->assertNotFound(); expect(Cache::has(FileBrowserDownloadController::cacheKey('token-a')))->toBeFalse(); $this->actingAs($this->admin)->withSession(['currentTeam' => $this->team]) ->get(route('project.files.download', 'unknown')) ->assertNotFound(); }); it('blocks members from the download route', function () { $this->actingAs($this->member)->withSession(['currentTeam' => $this->team]) ->get(route('project.files.download', 'any')) ->assertForbidden(); }); it('denies resources outside the current team even when the user belongs to both teams', function () { $database = fbRunningDatabase($this->environment, $this->destination); $otherTeam = Team::factory()->create(); $this->member->teams()->attach($otherTeam, ['role' => 'admin']); $this->actingAs($this->member); session(['currentTeam' => $otherTeam]); Process::fake(); Livewire::test(FileBrowser::class, ['resource' => $database])->assertForbidden(); Process::assertNothingRan(); }); it('does not use a container that stopped after mounting', function (string $action, array $arguments) { Process::fake(['*' => Process::result(output: '')]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); $database->update(['status' => 'exited']); $component->call($action, ...$arguments); })->with([ ['open', ['folder']], ['goTo', ['/data']], ['refresh', []], ])->throws(RuntimeException::class, 'Container is not running.'); it('lists files for a running application and excludes stopped and preview containers', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'status' => 'running', ]); $containers = collect([ ['ID' => 'base', 'Names' => 'app-base', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0'], ['ID' => 'stopped', 'Names' => 'app-stopped', 'State' => 'exited', 'Labels' => 'coolify.pullRequestId=0'], ['ID' => 'preview', 'Names' => 'app-preview', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=42'], ])->map(fn ($container) => json_encode($container))->implode("\n"); Process::fake(function ($process) use ($containers) { $output = match (true) { str_contains(remoteProcessScript($process), 'docker ps') => $containers, str_contains(remoteProcessScript($process), 'docker inspect') => '/app', default => "file\t12\t1\t644\tconfig.yml", }; return Process::result(output: $output); }); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $application]) ->call('loadFiles') ->assertSet('availableContainers', ['app-base']) ->assertSet('container', 'app-base') ->assertSet('currentPath', '/app') ->assertSee('config.yml') ->call('refresh') ->assertSet('container', 'app-base') ->assertSee('config.yml'); }); it('renders before SSH starts and loads files in a later request', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: "file\t0\t1\t644\tconfig.yml"))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]); Process::assertNothingRan(); $component->assertSet('filesLoaded', false) ->assertSet('container', null) ->call('loadFiles') ->assertSet('filesLoaded', true) ->assertSet('currentPath', '/data') ->assertSee('config.yml'); }); it('does not start SSH if the resource stops before deferred loading', function () { Process::fake(); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]); $database->update(['status' => 'exited']); $component->call('loadFiles') ->assertSet('filesLoaded', true) ->assertSet('containerRunning', false); Process::assertNothingRan(); }); it('allows retrying a failed initial container lookup', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'status' => 'running', ]); $fail = true; Process::fake(function ($process) use (&$fail) { if (str_contains(remoteProcessScript($process), 'docker ps')) { if ($fail) { $fail = false; return Process::result(errorOutput: 'Permission denied', exitCode: 1); } return Process::result(output: json_encode([ 'ID' => 'base', 'Names' => 'app-base', 'State' => 'running', 'Labels' => '', ])); } return Process::result(output: str_contains(remoteProcessScript($process), 'docker inspect') ? '/app' : ''); }); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $application]) ->call('loadFiles') ->assertSet('filesLoaded', false) ->assertSet('loadFailed', true) ->assertSet('container', null) ->call('loadFiles') ->assertSet('filesLoaded', true) ->assertSet('loadFailed', false) ->assertSet('container', 'app-base'); }); function fbConfigureDownloadStorage(): S3Storage { Team::factory()->create(['id' => 0]); $storage = S3Storage::create([ 'name' => 'Downloads', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'downloads', 'endpoint' => 'https://s3.example.com', 'is_usable' => true, 'team_id' => 0, ]); InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $storage->id]); Once::flush(); return $storage; } it('queues an S3 upload instead of streaming when download storage is set', function () { Bus::fake([PrepareFileDownloadJob::class]); $storage = fbConfigureDownloadStorage(); $overStreamCapKb = intdiv(ContainerFilesystemService::MAX_DOWNLOAD_SIZE, 1024) + 1; Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: "dir {$overStreamCapKb}"))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('download', 'folder') ->assertNoRedirect() ->assertDispatched('info'); expect($component->get('pendingDownloads'))->toHaveCount(1); Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->downloadId === $component->get('pendingDownloads')[0] && $job->userId === $this->admin->id && $job->path === '/data/folder' && $job->isDir && $job->downloadName === 'folder.tar.gz' && $job->s3StorageId === $storage->id); }); it('refuses S3 downloads above the S3 size cap', function () { Bus::fake([PrepareFileDownloadJob::class]); fbConfigureDownloadStorage(); Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: 'file '.(FileDownloadStorageService::MAX_SIZE + 1)))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('download', 'big.bin') ->assertDispatched('error'); Bus::assertNotDispatched(PrepareFileDownloadJob::class); }); it('opens a finished S3 download once and ignores entries of other users', function () { Bus::fake([PrepareFileDownloadJob::class]); fbConfigureDownloadStorage(); Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: 'file 10'))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('download', 'a.txt'); $downloadId = $component->get('pendingDownloads')[0]; $cacheKey = PrepareFileDownloadJob::cacheKey($downloadId); Cache::put($cacheKey, ['user_id' => $this->member->id, 'status' => 'ready', 'url' => 'https://s3.example.com/other'], 60); $component->call('checkDownloads') ->assertNotDispatched('open-file-download') ->assertSet('pendingDownloads', [$downloadId]); Cache::put($cacheKey, ['user_id' => $this->admin->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get'], 60); $component->call('checkDownloads') ->assertDispatched('open-file-download', url: 'https://s3.example.com/get') ->assertSet('pendingDownloads', []); expect(Cache::has($cacheKey))->toBeFalse(); }); it('does not hand out S3 links after the user lost access to the resource', function () { Bus::fake([PrepareFileDownloadJob::class]); fbConfigureDownloadStorage(); Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: 'file 10'))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('download', 'a.txt'); Cache::put(PrepareFileDownloadJob::cacheKey($component->get('pendingDownloads')[0]), ['user_id' => $this->admin->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get'], 60); $this->project->update(['team_id' => Team::factory()->create()->id]); $component->call('checkDownloads') ->assertForbidden() ->assertNotDispatched('open-file-download'); }); it('limits how many S3 downloads a user can start', function () { Bus::fake([PrepareFileDownloadJob::class]); fbConfigureDownloadStorage(); Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: 'file 10')) ->push(Process::result(output: 'file 10')) ->push(Process::result(output: 'file 10')) ->push(Process::result(output: 'file 10'))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); foreach (range(1, 4) as $attempt) { $component->call('download', 'a.txt'); } expect($component->get('pendingDownloads'))->toHaveCount(3); Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 3); $component->assertDispatched('error', 'Too many downloads are in progress. Wait for them to finish, then try again.'); }); it('blocks navigation before a container is selected', function (string $action, array $arguments) { Process::fake(); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]); expect(fn () => $component->call($action, ...$arguments)) ->toThrow(RuntimeException::class, 'Container is not running.'); expect($component->instance()->currentPath)->toBe(''); Process::assertNothingRan(); })->with([ ['open', ['folder']], ['goTo', ['/data']], ['refresh', []], ]); it('reduces upload names to one filename component', function (string $name, string $expected) { $component = new FileBrowser; $sanitize = new ReflectionMethod(FileBrowser::class, 'sanitizeUploadName'); expect($sanitize->invoke($component, $name))->toBe($expected); })->with([ ['../../etc/cron.d/payload', 'payload'], ['..\\..\\etc\\cron.d\\payload', 'payload'], ['../nested\\folder/payload', 'payload'], ["it's.txt", "it's.txt"], ['.env', '.env'], ['my file.txt', 'my file.txt'], ]); it('rejects empty and all-dot upload names', function (string $name) { $component = new FileBrowser; $sanitize = new ReflectionMethod(FileBrowser::class, 'sanitizeUploadName'); expect(fn () => $sanitize->invoke($component, $name)) ->toThrow(RuntimeException::class, 'Invalid file name.'); })->with(['', '/', '\\', '.', '..', '...', '../..', '..\\..']); it('refuses to save an opened file after its container identity changes', function () { Process::fake(['*' => Process::sequence() ->push(Process::result(output: '/data')) ->push(Process::result(output: '')) ->push(Process::result(output: "OK\n".base64_encode('before')))]); $database = fbRunningDatabase($this->environment, $this->destination); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); $component = Livewire::test(FileBrowser::class, ['resource' => $database]) ->call('loadFiles') ->call('openEditor', 'config.yml') ->set('editorContent', 'after'); $database->update(['uuid' => (string) Str::uuid()]); Process::swap(new Factory); Process::fake(); expect(fn () => $component->call('saveEditor')) ->toThrow(RuntimeException::class, 'Container is not running.'); expect($component->instance()->editingPath)->toBe('/data/config.yml'); Process::assertNothingRan(); });