mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
Allow validated host file and directory mounts outside resource directories while protecting external paths from deletion. Clean up cancelled deployment containers and clear tracked process IDs. Preserve legacy KeyDB and Dragonfly password parsing, accept server backup paths without extension restrictions, and allow Compose variable proxy ports.
327 lines
13 KiB
PHP
327 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Jobs\ServerStorageSaveJob;
|
|
use App\Livewire\Project\Service\FileStorage;
|
|
use App\Livewire\Project\Service\Storage;
|
|
use App\Livewire\Project\Shared\Storages\All;
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
|
Bus::fake();
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->admin = User::factory()->create();
|
|
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
|
|
|
$keyId = DB::table('private_keys')->insertGetId([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'Test Key',
|
|
'private_key' => Crypt::encryptString('test-key'),
|
|
'team_id' => $this->team->id,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => $keyId,
|
|
]);
|
|
Process::fake(fn () => Process::result(output: 'OK'));
|
|
|
|
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()
|
|
?? Environment::factory()->create(['project_id' => $this->project->id]);
|
|
|
|
$this->application = Application::factory()->create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'Test App',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
$this->actingAs($this->admin);
|
|
session(['currentTeam' => $this->team]);
|
|
});
|
|
|
|
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_path', '/../../../../../../etc/example.conf')
|
|
->set('file_storage_content', 'owned')
|
|
->call('submitFileStorage')
|
|
->assertDispatched('error');
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('livewire directory storage accepts a server absolute path for an administrator', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', '/root/.ssh')
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('success');
|
|
|
|
expect(LocalFileVolume::query()->sole()->fs_path)->toBe('/root/.ssh');
|
|
});
|
|
|
|
test('livewire directory storage accepts a path inside the application root', function () {
|
|
$source = application_configuration_dir().'/'.$this->application->uuid.'/data/new';
|
|
|
|
$component = Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', $source)
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory');
|
|
$component->assertDispatched('success');
|
|
|
|
expect(LocalFileVolume::query()->sole()->fs_path)->toBe($source);
|
|
});
|
|
|
|
test('livewire directory storage rejects a remote symlink escape without side effects', function () {
|
|
Process::fake(fn () => Process::result(output: 'NOK'));
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', $this->application->workdir().'/linked/outside')
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('error');
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
|
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
|
});
|
|
|
|
test('normal team members cannot create directory storage', function () {
|
|
$member = User::factory()->create();
|
|
$member->teams()->attach($this->team, ['role' => 'member']);
|
|
$this->actingAs($member);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_directory_source', application_configuration_dir().'/'.$this->application->uuid.'/data')
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('error');
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('an administrator cannot create storage on a cross-team resource', function () {
|
|
$otherTeam = Team::factory()->create();
|
|
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
|
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
|
$otherApplication = Application::factory()->create([
|
|
'environment_id' => $otherEnvironment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
Livewire::test(Storage::class, ['resource' => $otherApplication])
|
|
->set('file_storage_directory_source', application_configuration_dir().'/'.$otherApplication->uuid.'/data')
|
|
->set('file_storage_directory_destination', '/data')
|
|
->call('submitFileStorageDirectory')
|
|
->assertDispatched('error');
|
|
|
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('file mount modal shows the calculated host file path above the destination input', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->assertSeeText('Create a managed file on the host and mount it inside the container.')
|
|
->assertSeeText('Host file path')
|
|
->assertSeeText($this->application->workdir().'/')
|
|
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
|
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
|
|
->assertDontSeeText('Actual file mounted from the host system to the container.');
|
|
});
|
|
|
|
test('livewire file storage stores safe file mounts under the application configuration root', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
|
->set('file_storage_content', 'server {}')
|
|
->call('submitFileStorage')
|
|
->assertDispatched('success')
|
|
->assertDispatched('configurationChanged');
|
|
|
|
$volume = LocalFileVolume::query()->sole();
|
|
|
|
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
|
|
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
|
|
->and($volume->is_directory)->toBeFalse();
|
|
});
|
|
|
|
test('livewire host file storage stores an existing host file path without managed content', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
|
|
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
|
|
->call('submitHostFileStorage')
|
|
->assertDispatched('success')
|
|
->assertDispatched('configurationChanged');
|
|
|
|
$volume = LocalFileVolume::query()->sole();
|
|
|
|
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
|
|
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
|
|
->and($volume->content)->toBeNull()
|
|
->and($volume->is_host_file)->toBeTrue()
|
|
->and($volume->is_directory)->toBeFalse();
|
|
|
|
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
|
});
|
|
|
|
test('livewire volume storage refreshes the storage list and configuration warning', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('name', 'data')
|
|
->set('mount_path', '/app/data')
|
|
->call('submitPersistentVolume')
|
|
->assertDispatched('success')
|
|
->assertNotDispatched('refreshStorages')
|
|
->assertNotDispatched('refreshVolumeList')
|
|
->assertDispatched('configurationChanged')
|
|
->assertSet('activeTab', 'volumes')
|
|
->assertSet('volumeCount', 1)
|
|
->assertSee($this->application->uuid.'-data');
|
|
});
|
|
|
|
test('adding another volume refreshes only the mounted volume list', function () {
|
|
LocalPersistentVolume::create([
|
|
'name' => $this->application->uuid.'-first',
|
|
'mount_path' => '/app/first',
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
]);
|
|
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->set('name', 'second')
|
|
->set('mount_path', '/app/second')
|
|
->call('submitPersistentVolume')
|
|
->assertDispatched('refreshVolumeList')
|
|
->assertNotDispatched('refreshStorages');
|
|
});
|
|
|
|
test('deleting a volume asks only the parent storage component to refresh its counts', function () {
|
|
$volume = LocalPersistentVolume::create([
|
|
'name' => $this->application->uuid.'-data',
|
|
'mount_path' => '/app/data',
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
]);
|
|
|
|
Livewire::test(All::class, ['resource' => $this->application])
|
|
->call('delete', $volume->id, 'password')
|
|
->assertDispatched('storageCountsChanged')
|
|
->assertNotDispatched('refreshStorages');
|
|
});
|
|
|
|
test('volume storage list shows volumes added after it was mounted', function () {
|
|
$firstVolume = LocalPersistentVolume::create([
|
|
'name' => $this->application->uuid.'-first',
|
|
'mount_path' => '/app/first',
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
]);
|
|
|
|
$storageList = Livewire::test(All::class, ['resource' => $this->application])
|
|
->assertSee($firstVolume->name);
|
|
|
|
$secondVolume = LocalPersistentVolume::create([
|
|
'name' => $this->application->uuid.'-second',
|
|
'mount_path' => '/app/second',
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
]);
|
|
|
|
$storageList
|
|
->assertDontSee($secondVolume->name)
|
|
->call('refreshList')
|
|
->assertSee($secondVolume->name);
|
|
});
|
|
|
|
test('adding a volume switches to the volumes tab immediately', function () {
|
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
|
->assertSet('activeTab', 'volumes')
|
|
->set('activeTab', 'directories')
|
|
->set('name', 'cache')
|
|
->set('mount_path', '/cache')
|
|
->call('submitPersistentVolume')
|
|
->assertSet('activeTab', 'volumes')
|
|
->assertSee($this->application->uuid.'-cache')
|
|
->assertDontSee('No directory mounts configured');
|
|
});
|
|
|
|
test('deleting a file mount refreshes the configuration warning', function () {
|
|
$file = LocalFileVolume::create([
|
|
'fs_path' => '/etc/nginx/nginx.conf',
|
|
'mount_path' => '/etc/nginx/nginx.conf',
|
|
'is_host_file' => true,
|
|
'is_based_on_git' => false,
|
|
'is_preview_suffix_enabled' => true,
|
|
'resource_id' => $this->application->id,
|
|
'resource_type' => $this->application->getMorphClass(),
|
|
]);
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $file])
|
|
->call('delete', 'password')
|
|
->assertDispatched('configurationChanged')
|
|
->assertDispatched('storageCountsChanged')
|
|
->assertNotDispatched('refreshStorages');
|
|
|
|
expect($file->fresh())->toBeNull();
|
|
});
|
|
|
|
test('deleting a volume mount refreshes the configuration warning', function () {
|
|
$database = StandalonePostgresql::create([
|
|
'name' => 'test-postgres',
|
|
'image' => 'postgres:15-alpine',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'postgres',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
$volume = LocalPersistentVolume::create([
|
|
'name' => $database->uuid.'-data',
|
|
'mount_path' => '/var/lib/postgresql/data',
|
|
'resource_id' => $database->id,
|
|
'resource_type' => $database->getMorphClass(),
|
|
]);
|
|
|
|
Livewire::test(All::class, ['resource' => $database])
|
|
->call('delete', $volume->id, 'password')
|
|
->assertDispatched('configurationChanged');
|
|
|
|
expect($volume->fresh())->toBeNull();
|
|
});
|