test: remove tests that do not exercise app code

Remove tests that copy app logic into the test, grep PHP source as
text, only check literals, constants, class_exists or PHP built-ins,
duplicate other tests, or are always skipped. Keep source checks that
guard security and authorization rules.

Rename a duplicate Pest helper so the full suite loads, drop the
missing v5/Browser path, and remove the CLAUDE.md symlink in favor of
AGENTS.md. Correct AGENTS.md: Dusk is not installed and the upgrade
script is scripts/upgrade.sh.
This commit is contained in:
Andras Bacsai
2026-09-28 11:55:56 +02:00
parent 59788c59f0
commit 1f4006ac49
105 changed files with 15 additions and 7557 deletions
+2
View File
@@ -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.
+2 -2
View File
@@ -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`
+4 -6
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
AGENTS.md
@@ -1,8 +1,5 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Actions\Service\StopServiceApplication;
use App\Jobs\PushServerUpdateJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ServiceApplication;
@@ -44,48 +41,7 @@ it('gives independently runnable application resources restart limit state', fun
[ServiceApplication::class],
]);
it('collects restart counts for preview and service containers from 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('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);
}
@@ -1,26 +0,0 @@
<?php
use App\Models\Application;
it('stores nullable application container presence as a boolean', function () {
$application = new Application;
$application->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");
});
@@ -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 () {
@@ -1,8 +1,6 @@
<?php
use App\Actions\Application\StopApplication;
use App\Actions\Docker\GetContainersStatus;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\BaseModel;
@@ -109,67 +107,6 @@ it('does not show the restart limit warning for a normal manual stop', function
expect($html)->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([
-27
View File
@@ -1,7 +1,5 @@
<?php
use App\Http\Kernel;
use App\Livewire\Project\Service\Heading;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Livewire\Team\AuditLog;
use App\Livewire\Team\Index as TeamIndex;
@@ -34,7 +32,6 @@ use App\Traits\Auditable;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Logout;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
@@ -94,13 +91,6 @@ test('multiple audit inserts in one request are all deferred', function () {
]);
});
test('http kernel invokes deferred callbacks', function () {
$kernel = app(Kernel::class);
$middleware = (new ReflectionClass($kernel))->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,
-14
View File
@@ -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('<x-forms.button x-bind:disabled="saving">Save changes</x-forms.button>');
@@ -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']);
@@ -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');
});
});
@@ -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}"');
});
@@ -1,10 +0,0 @@
<?php
it('exposes enable and disable public access methods on service index', function () {
$source = file_get_contents(app_path('Livewire/Project/Service/Index.php'));
expect($source)
->toContain('public function enablePublicAccess(): void')
->toContain('public function disablePublicAccess(): void')
->toContain('$this->instantSave()');
});
@@ -1,22 +0,0 @@
<?php
test('managed environment variables are ordered first', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
expect($component)
->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)');
});
@@ -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
@@ -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);');
});
@@ -1,32 +0,0 @@
<?php
it('persists a restart limit notification preference for every channel', function (string $channel) {
$studly = Str::studly($channel);
$component = file_get_contents(app_path("Livewire/Notifications/{$studly}.php"));
$model = file_get_contents(app_path('Models/'.$studly.'NotificationSettings.php'));
$column = "restart_limit_reached_{$channel}_notifications";
$property = "restartLimitReached{$studly}Notifications";
expect($component)
->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'])");
});
@@ -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',
-176
View File
@@ -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([
@@ -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)
-5
View File
@@ -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 () {
@@ -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();
@@ -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'));
@@ -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();
@@ -1,10 +0,0 @@
<?php
test('server role migration keeps dedicated build servers and defaults other servers to both', function () {
$migration = file_get_contents(base_path('database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php'));
expect($migration)
->toContain('->nullable()->default(ServerRole::BOTH->value)')
->toContain("->where('is_build_server', true)")
->toContain("->update(['server_role' => ServerRole::BUILD->value])");
});
@@ -1,220 +0,0 @@
<?php
/**
* Feature tests to verify that FQDN updates don't cause path duplication
* for services with path-based SERVICE_URL/SERVICE_FQDN template variables.
*
* This tests the fix for GitHub issue #7363 where Appwrite and MindsDB services
* had their paths duplicated (e.g., /v1/realtime/v1/realtime) after FQDN updates.
*
* IMPORTANT: These tests require database access and must be run inside Docker:
* docker exec coolify php artisan test --filter ServiceFqdnUpdatePathTest
*/
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('Appwrite realtime service does not duplicate path on FQDN update', function () {
// Create a server
$server = Server::factory()->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');
@@ -1,14 +1,5 @@
<?php
use App\Livewire\Project\Service\DatabaseBackups;
it('declares the database backup mount return type', function () {
$returnType = (new ReflectionMethod(DatabaseBackups::class, 'mount'))->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'));
@@ -1,171 +0,0 @@
<?php
/**
* Feature tests to verify that magic (referenced) SERVICE_URL_/SERVICE_FQDN_
* variables do not overwrite values set by direct template declarations or updateCompose().
*
* This tests the fix for GitHub issue #8912 where generic SERVICE_URL and SERVICE_FQDN
* variables remained stale after changing a service domain in the UI, while
* port-specific variants updated correctly.
*
* Root cause: The magic variables section in serviceParser() used updateOrCreate()
* which overwrote values from direct template declarations with auto-generated FQDNs.
* Fix: Changed to firstOrCreate() so magic references don't overwrite existing values.
*
* IMPORTANT: These tests require database access and must be run inside Docker:
* docker exec coolify php artisan test --filter ServiceMagicVariableOverwriteTest
*/
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('generic SERVICE_URL/FQDN vars update after domain change when referenced by other services', function () {
$server = Server::factory()->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');
});
@@ -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'));
-6
View File
@@ -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');
-7
View File
@@ -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);
@@ -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');
-20
View File
@@ -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');
});
-19
View File
@@ -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();
+1 -1
View File
@@ -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');
/*
|--------------------------------------------------------------------------
@@ -1,458 +0,0 @@
<?php
beforeEach(function () {
Mockery::close();
});
afterEach(function () {
Mockery::close();
});
it('categorizes images correctly into PR, build, and regular images', 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' => '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);
});
@@ -1,223 +0,0 @@
<?php
/**
* Unit tests to verify consistent handling of all-excluded containers
* across PushServerUpdateJob, GetContainersStatus, and ComplexStatusCheck.
*
* These tests verify the fix for issue where different code paths handled
* all-excluded containers inconsistently:
* - PushServerUpdateJob (Sentinel, ~30s) previously skipped updates
* - GetContainersStatus (SSH, ~1min) previously skipped updates
* - ComplexStatusCheck (Multi-server) correctly calculated :excluded status
*
* After this fix, all three paths now calculate and return :excluded status
* consistently, preventing status drift and UI inconsistencies.
*/
it('ensures CalculatesExcludedStatus trait exists with required methods', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Verify trait has both status calculation methods
expect($traitFile)
->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');
});
-26
View File
@@ -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();
@@ -1,49 +0,0 @@
<?php
/**
* Unit test to verify that custom_network_aliases is included in configuration change detection.
* Tests the behavior of the isConfigurationChanged method by verifying that different
* custom_network_aliases values produce different configuration hashes.
*/
it('different custom_network_aliases values produce different hashes', function () {
// Test that the hash calculation includes custom_network_aliases by computing hashes with different values
$hash1 = md5(base64_encode('test'.'api.internal,api.local'));
$hash2 = md5(base64_encode('test'.'api.internal,api.local,api.staging'));
$hash3 = md5(base64_encode('test'.null));
expect($hash1)->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
});
@@ -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';
@@ -1,63 +0,0 @@
<?php
/**
* Test to verify that empty .env files are created for build packs that require them.
*
* This test verifies the fix for the issue where deploying a Docker image without
* environment variables would fail because Docker Compose expects a .env file
* when env_file: ['.env'] is specified in the compose file.
*
* The fix ensures that for 'dockerimage' and 'dockercompose' build packs,
* an empty .env file is created even when there are no environment variables defined.
*/
it('determines which build packs require empty .env file creation', function () {
// Build packs that set env_file: ['.env'] in the generated compose file
// and thus require an empty .env file even when no environment variables are defined
$buildPacksRequiringEnvFile = ['dockerimage', 'dockercompose'];
// Build packs that don't use env_file in the compose file
$buildPacksNotRequiringEnvFile = ['dockerfile', 'nixpacks', 'static'];
foreach ($buildPacksRequiringEnvFile as $buildPack) {
// Verify the condition matches our fix
$requiresEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
expect($requiresEnvFile)->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()"
);
}
});
@@ -1,11 +0,0 @@
<?php
it('persists buildx metadata between the helper container and host cleanup', function () {
$sourceFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
expect($sourceFile)
->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);
});
@@ -1,10 +0,0 @@
<?php
it('respects the instance SPA navigation setting after application actions', function () {
$heading = file_get_contents(__DIR__.'/../../app/Livewire/Project/Application/Heading.php');
expect(substr_count($heading, "return redirectRoute(\$this, 'project.application.deployment.show'"))
->toBe(2)
->and($heading)
->not->toContain('navigate: false');
});
@@ -1,187 +0,0 @@
<?php
use Illuminate\Support\Stringable;
/**
* Unit tests to verify that the applicationParser function in parsers.php
* properly converts Stringable objects to plain strings to fix strict
* comparison and collection key lookup issues.
*
* Related issue: Lines 539 and 541 in parsers.php were creating Stringable
* objects which caused:
* - Strict comparisons (===) to fail (line 606)
* - Collection key lookups to fail (line 615)
*/
it('ensures service name normalization returns plain strings not Stringable objects', function () {
// Test the exact transformations that happen in parsers.php lines 539-541
// Simulate what happens at line 520
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service');
$serviceName = $parsed['service_name']; // 'my-service'
// Line 539: $originalServiceName = str($serviceName)->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');
});
@@ -1,190 +0,0 @@
<?php
/**
* Unit tests to verify that Applications using Docker Compose handle
* SERVICE_URL and SERVICE_FQDN environment variables correctly.
*
* This ensures consistency with Service behavior where BOTH URL and FQDN
* pairs are always created together, regardless of which one is in the template.
*/
it('ensures parsers.php creates both URL and FQDN pairs for applications', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that the fix is in place
expect($parsersFile)->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}');
});
-47
View File
@@ -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'];
-34
View File
@@ -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([
-17
View File
@@ -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);
});
@@ -1,76 +0,0 @@
<?php
// Unit tests for cloud-init script validation logic
it('validates cloud-init script is optional', function () {
$cloudInitScript = null;
$isRequired = false;
$hasValue = ! empty($cloudInitScript);
expect($isRequired)->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);
});
-344
View File
@@ -1,344 +0,0 @@
<?php
use App\Models\Application;
use App\Services\ContainerStatusAggregator;
/**
* Unit tests to verify that containers without health checks are not
* incorrectly marked as unhealthy.
*
* This tests the fix for the issue where defaulting missing health status
* to 'unhealthy' would treat containers without healthchecks as unhealthy.
*
* The fix removes the 'unhealthy' default and only checks health status
* when it explicitly exists and equals 'unhealthy'.
*/
it('does not mark containers as unhealthy when health status is missing', function () {
// Mock an application with a server
$application = Mockery::mock(Application::class)->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;');
});
-84
View File
@@ -1,84 +0,0 @@
<?php
use App\Jobs\CoolifyTask;
it('CoolifyTask has failed method that handles cleanup', function () {
$reflection = new ReflectionClass(CoolifyTask::class);
// Verify failed method exists
expect($reflection->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();
});
-101
View File
@@ -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=<escaped>, 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);
@@ -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 () {
@@ -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 () {
@@ -1,207 +0,0 @@
<?php
/**
* GHSA-rcch-8c74-7f29 — Sink-side escaping tests
*
* Verifies that credentials reaching shell commands are properly escaped
* even if validation is bypassed (e.g. legacy rows, direct DB writes).
*/
// ── executeInDocker + escapeshellarg chown pattern ────────────────────────────
it('escapeshellarg wraps postgres_user in single quotes for chown command', function () {
$user = 'postgres';
$escaped = escapeshellarg($user);
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
// executeInDocker embeds the command inside bash -c '...', escaping inner single quotes as '\''
// so escapeshellarg('postgres') = 'postgres' becomes '\''postgres'\'' in the outer shell string
expect($cmd)->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');
});
@@ -1,84 +0,0 @@
<?php
it('reuses resolved environment credentials in database startup integrations', function (string $action, array $expected, array $unexpected) {
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
expect($source)->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;']],
]);
-8
View File
@@ -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));');
});
-26
View File
@@ -1,26 +0,0 @@
<?php
it('formats image progress output consistently', function (string $action) {
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
expect($source)->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)',
]);
-10
View File
@@ -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');
-14
View File
@@ -1,14 +0,0 @@
<?php
it('defines OpenAPI documentation for every destinations endpoint', function () {
$controller = file_get_contents(__DIR__.'/../../app/Http/Controllers/Api/DestinationsController.php');
$model = file_get_contents(__DIR__.'/../../app/Models/StandaloneDocker.php');
expect($model)->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'");
});
@@ -1,296 +0,0 @@
<?php
use Symfony\Component\Yaml\Yaml;
/**
* Unit tests to verify that environment variables with empty strings
* in Docker Compose files are preserved as empty strings, not converted to null.
*
* This is important because empty strings and null have different semantics in Docker:
* - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
* - Null: Variable is unset/removed from container environment
*
* See: https://github.com/coollabsio/coolify/issues/7126
*/
it('ensures parsers.php preserves empty strings in application parser', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Find the applicationParser function's environment mapping logic
$hasApplicationParser = str_contains($parsersFile, 'function applicationParser(');
expect($hasApplicationParser)->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();
});
@@ -1,194 +0,0 @@
<?php
use Symfony\Component\Yaml\Yaml;
/**
* Unit tests to verify that empty top-level sections (volumes, configs, secrets)
* are removed from generated Docker Compose files.
*
* Empty sections like "volumes: { }" are not valid/clean YAML and should be omitted
* when they contain no actual content.
*/
it('ensures parsers.php filters empty top-level sections', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that filtering logic exists
expect($parsersFile)
->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);
});
@@ -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;
@@ -1,94 +0,0 @@
<?php
/**
* Unit tests to verify that docker compose label parsing correctly handles
* labels defined as YAML key-value pairs (e.g., "traefik.enable: true")
* which get parsed as arrays instead of strings.
*
* This test verifies the fix for the "preg_match(): Argument #2 ($subject) must
* be of type string, array given" error.
*/
it('ensures label parsing handles array values from YAML', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Check that array handling is present before str() call
expect($sharedFile)
->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);
});
@@ -1,110 +0,0 @@
<?php
/**
* Unit tests to verify that docker_compose_raw only has content: removed from volumes,
* while docker_compose contains all Coolify additions (labels, environment variables, networks).
*
* These tests verify the fix for the issue where docker_compose_raw was being set to the
* fully processed compose (with Coolify labels, networks, etc.) instead of keeping it clean
* with only content: fields removed.
*/
it('ensures applicationParser stores original compose before processing', function () {
// Read the applicationParser 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('$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);
});
-131
View File
@@ -1,131 +0,0 @@
<?php
/**
* Unit tests to verify that applications and services with all containers
* excluded from health checks (exclude_from_hc: true) show correct status.
*
* These tests verify the fix for the issue where services with all containers
* excluded would show incorrect status, causing broken UI state.
*
* The fix now returns status with :excluded suffix to show real container state
* while indicating monitoring is disabled (e.g., "running:excluded").
*/
it('ensures ComplexStatusCheck returns excluded status when all containers excluded', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
// Check that when all containers are excluded, ComplexStatusCheck uses the trait
expect($complexStatusCheckFile)
->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\'');
});
-39
View File
@@ -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' =>");
});
@@ -1,54 +0,0 @@
<?php
/**
* Unit tests verifying that GetContainersStatus has empty container
* safeguards for ALL resource types (applications, previews, databases, services).
*
* When Docker queries fail and return empty container lists, resources should NOT
* be falsely marked as "exited". This was originally added for applications and
* previews (commit 684bd823c) but was missing for databases and services.
*
* @see https://github.com/coollabsio/coolify/issues/8826
*/
it('has empty container safeguard for applications', function () {
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// The safeguard should appear before marking applications as exited
expect($actionFile)
->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()');
});
@@ -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);');
});
-77
View File
@@ -1,77 +0,0 @@
<?php
uses(\Tests\TestCase::class);
it('extracts commit SHA from git ls-remote output without warnings', 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 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();
});
-53
View File
@@ -1,53 +0,0 @@
<?php
it('merges Coolify key with selected Hetzner keys', function () {
$coolifyKeyId = 123;
$selectedHetznerKeys = [456, 789];
// Simulate the merge logic from createHetznerServer
$sshKeys = array_merge(
[$coolifyKeyId],
$selectedHetznerKeys
);
expect($sshKeys)->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();
}
});
@@ -1,181 +0,0 @@
<?php
use App\Livewire\Boarding\Index;
use App\Models\Activity;
use App\Models\Server;
/**
* These tests verify the fix for the prerequisite installation race condition.
* The key behavior is that installation runs asynchronously via Activity,
* and revalidation only happens after the ActivityMonitor callback.
*/
it('dispatches activity to monitor when prerequisites are missing', function () {
// This test verifies the core fix: that we dispatch to ActivityMonitor
// instead of immediately revalidating after starting installation.
$server = Mockery::mock(Server::class)->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();
});
@@ -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) {');
});
@@ -1,121 +0,0 @@
<?php
test('DockerCompose handles array format from parseEnvFormatToArray', function () {
// Simulate the array format that parseEnvFormatToArray returns
$variables = [
'KEY1' => ['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();
}
}
});
@@ -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;
-32
View File
@@ -1,16 +1,5 @@
<?php
function renderedDataLineTextValue(string $logContent): string
{
$escapedContent = e($logContent);
$html = '<span data-line-text="'.$escapedContent.'">'.$escapedContent.'</span>';
$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 = '<div>A</div>';
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 = '<script>alert("XSS")</script>';
$escapedLog = e($maliciousLog);
expect($escapedLog)
->toContain('&lt;script&gt;')
->not->toContain('<script>');
});
it('keeps dangerous attributes as literal dataset text for textContent rendering', function () {
$maliciousLog = '<img src=x onerror="alert(1)">';
expect(renderedDataLineTextValue($maliciousLog))->toBe($maliciousLog);
});
it('uses text nodes for search highlighting instead of injected html', function () {
$deploymentView = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php');
@@ -6,32 +6,6 @@
* These tests verify that the parser correctly handles nested variable substitution syntax
* like ${API_URL:-${SERVICE_URL_YOLO}/api} where defaults can contain other variable references.
*/
test('nested variable syntax is parsed correctly', function () {
// Test the exact scenario from the bug report
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
$result = extractBalancedBraceContent($input, 0);
expect($result)->not->toBeNull()
->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
$split = splitOnOperatorOutsideNested($result['content']);
expect($split)->not->toBeNull()
->and($split['variable'])->toBe('API_URL')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
});
test('replaceVariables correctly extracts nested variable content', function () {
// Before the fix, this would incorrectly extract only up to the first closing brace
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
// Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
});
test('nested defaults with path concatenation work', function () {
$input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
@@ -79,15 +53,6 @@ test('nested variables with different operators', function () {
->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
});
test('backward compatibility with simple variables', function () {
// Simple variable without nesting should still work
$input = '${VAR}';
$result = replaceVariables($input);
expect($result->value())->toBe('VAR');
});
test('backward compatibility with single-level defaults', function () {
// Single-level default without nesting
$input = '${VAR:-default_value}';
@@ -88,90 +88,3 @@ describe('replaceLocalSource', function () {
expect((string) $result)->toBe('/app/data');
});
});
/**
* Source-code structure tests for parser and deployment job.
*
* These verify that key code patterns exist in the parser and deployment job.
* They are intentionally text-based because the parser/deployment functions
* require database-persisted models with deep relationships, making behavioral
* unit tests impractical. Full behavioral coverage should be done via Feature tests.
*/
describe('parser structure: bind mount handling', function () {
it('checks is_preview_suffix_enabled before applying suffix', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')");
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
$bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart);
expect($bindBlock)
->toContain('$isPreviewSuffixEnabled')
->toContain('is_preview_suffix_enabled')
->toContain('addPreviewDeploymentSuffix');
});
it('applies preview suffix to named volumes', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
$volumeBlockEnd = strpos($parsersFile, 'LocalPersistentVolume::updateOrCreate', $volumeBlockStart);
$volumeBlock = substr($parsersFile, $volumeBlockStart, $volumeBlockEnd - $volumeBlockStart);
expect($volumeBlock)->toContain('addPreviewDeploymentSuffix');
});
});
describe('parser structure: label generation uuid isolation', function () {
it('uses labelUuid instead of mutating shared uuid', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;');
$labelBlock = substr($parsersFile, $labelBlockStart, 300);
expect($labelBlock)
->toContain('$labelUuid = $resource->uuid')
->not->toContain('$uuid = $resource->uuid')
->not->toContain('$uuid = "{$resource->uuid}');
});
it('uses labelUuid in all proxy label generation calls', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly');
$labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')");
$labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart);
expect($labelBlock)
->toContain('uuid: $labelUuid')
->not->toContain('uuid: $uuid');
});
});
describe('deployment job structure: is_preview_suffix_enabled', function () {
it('checks setting in generate_local_persistent_volumes', function () {
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()');
$methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
expect($methodBlock)
->toContain('is_preview_suffix_enabled')
->toContain('$isPreviewSuffixEnabled')
->toContain('addPreviewDeploymentSuffix');
});
it('checks setting in generate_local_persistent_volumes_only_volume_names', function () {
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
$methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()');
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
expect($methodBlock)
->toContain('is_preview_suffix_enabled')
->toContain('$isPreviewSuffixEnabled')
->toContain('addPreviewDeploymentSuffix');
});
});
-13
View File
@@ -246,16 +246,3 @@ YAML);
expect($application->docker_compose_raw)->toContain('appdata');
});
it('still uses oldRawParser for raw compose deployments after git compose load', function () {
$jobSource = file_get_contents(base_path('app/Jobs/ApplicationDeploymentJob.php'));
$parserSource = file_get_contents(base_path('app/Models/Application.php'));
expect($jobSource)
->toContain('loadComposeFile(isInit: false)')
->toContain('is_raw_compose_deployment_enabled')
->toContain('oldRawParser()')
->and($parserSource)
->toContain('rawComposeBindMkdirCommand(')
->toContain('escapeshellarg');
});
@@ -1,61 +0,0 @@
<?php
/**
* Security tests for RestoreJobFinished event to ensure it uses secure path validation.
*/
describe('RestoreJobFinished event security', function () {
it('validates that safe paths pass validation', function () {
$validPaths = [
'/tmp/restore-backup.sql',
'/tmp/restore-script.sh',
'/tmp/database-dump-'.uniqid().'.sql',
];
foreach ($validPaths as $path) {
expect(isSafeTmpPath($path))->toBeTrue();
}
});
it('validates that malicious paths fail validation', function () {
$maliciousPaths = [
'/tmp/../etc/passwd',
'/tmp/foo/../../etc/shadow',
'/etc/sensitive-file',
'/var/www/config.php',
'/tmp/../../../root/.ssh/id_rsa',
];
foreach ($maliciousPaths as $path) {
expect(isSafeTmpPath($path))->toBeFalse();
}
});
it('rejects URL-encoded path traversal attempts', function () {
$encodedTraversalPaths = [
'/tmp/%2e%2e/etc/passwd',
'/tmp/foo%2f%2e%2e%2f%2e%2e/etc/shadow',
urlencode('/tmp/../etc/passwd'),
];
foreach ($encodedTraversalPaths as $path) {
expect(isSafeTmpPath($path))->toBeFalse();
}
});
it('handles edge cases correctly', function () {
// Too short
expect(isSafeTmpPath('/tmp'))->toBeFalse();
expect(isSafeTmpPath('/tmp/'))->toBeFalse();
// Null/empty
expect(isSafeTmpPath(null))->toBeFalse();
expect(isSafeTmpPath(''))->toBeFalse();
// Null byte injection
expect(isSafeTmpPath("/tmp/file.sql\0../../etc/passwd"))->toBeFalse();
// Valid edge cases
expect(isSafeTmpPath('/tmp/x'))->toBeTrue();
expect(isSafeTmpPath('/tmp/very/deeply/nested/path/to/file.sql'))->toBeTrue();
});
});
@@ -1,118 +0,0 @@
<?php
/**
* Security tests for shell metacharacter escaping in restore events.
*
* These tests verify that escapeshellarg() properly neutralizes shell injection
* attempts in paths that pass isSafeTmpPath() validation.
*/
describe('Shell metacharacter escaping in restore events', function () {
it('demonstrates that malicious paths can pass isSafeTmpPath but are neutralized by escapeshellarg', function () {
// This path passes isSafeTmpPath() validation (it's within /tmp/, no .., no null bytes)
$maliciousPath = "/tmp/file'; whoami; '";
// Path validation passes - it's a valid /tmp/ path
expect(isSafeTmpPath($maliciousPath))->toBeTrue();
// But when escaped, the shell metacharacters become literal strings
$escaped = escapeshellarg($maliciousPath);
// The escaped version wraps in single quotes and escapes internal single quotes
expect($escaped)->toBe("'/tmp/file'\\''; whoami; '\\'''");
// Building a command with escaped path is safe
$command = "rm -f {$escaped}";
// The command contains the quoted path, not an unquoted injection
expect($command)->toStartWith("rm -f '");
expect($command)->toEndWith("'");
});
it('escapes paths with semicolon injection attempts', function () {
$path = '/tmp/backup; rm -rf /; echo';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup; rm -rf /; echo'");
// The semicolons are inside quotes, so they're treated as literals
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup; rm -rf /; echo'");
});
it('escapes paths with backtick command substitution attempts', function () {
$path = '/tmp/backup`whoami`.sql';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup`whoami`.sql'");
// Backticks inside single quotes are not executed
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup`whoami`.sql'");
});
it('escapes paths with $() command substitution attempts', function () {
$path = '/tmp/backup$(id).sql';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup\$(id).sql'");
// $() inside single quotes is not executed
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup\$(id).sql'");
});
it('escapes paths with pipe injection attempts', function () {
$path = '/tmp/backup | cat /etc/passwd';
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
expect($escaped)->toBe("'/tmp/backup | cat /etc/passwd'");
// Pipe inside single quotes is treated as literal
$command = "rm -f {$escaped}";
expect($command)->toBe("rm -f '/tmp/backup | cat /etc/passwd'");
});
it('escapes paths with newline injection attempts', function () {
$path = "/tmp/backup\nwhoami";
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
// Newline is preserved inside single quotes
expect($escaped)->toContain("\n");
expect($escaped)->toStartWith("'");
expect($escaped)->toEndWith("'");
});
it('handles normal paths without issues', function () {
$normalPaths = [
'/tmp/restore-backup.sql',
'/tmp/restore-script.sh',
'/tmp/database-dump-abc123.sql',
'/tmp/deeply/nested/path/to/file.sql',
];
foreach ($normalPaths as $path) {
expect(isSafeTmpPath($path))->toBeTrue();
$escaped = escapeshellarg($path);
// Normal paths are just wrapped in single quotes
expect($escaped)->toBe("'{$path}'");
}
});
it('escapes container names with injection attempts', function () {
// Container names are not validated by isSafeTmpPath, so escaping is critical
$maliciousContainer = 'container"; rm -rf /; echo "pwned';
$escaped = escapeshellarg($maliciousContainer);
expect($escaped)->toBe("'container\"; rm -rf /; echo \"pwned'");
// Building a docker command with escaped container is safe
$command = "docker rm -f {$escaped}";
expect($command)->toBe("docker rm -f 'container\"; rm -rf /; echo \"pwned'");
});
});
-54
View File
@@ -1,59 +1,5 @@
<?php
test('S3 path is cleaned correctly', function () {
// Test that leading slashes are removed
$path = '/backups/database.gz';
$cleanPath = ltrim($path, '/');
expect($cleanPath)->toBe('backups/database.gz');
// Test path without leading slash remains unchanged
$path2 = 'backups/database.gz';
$cleanPath2 = ltrim($path2, '/');
expect($cleanPath2)->toBe('backups/database.gz');
});
test('S3 container name is generated correctly', function () {
$resourceUuid = 'test-database-uuid';
$containerName = "s3-restore-{$resourceUuid}";
expect($containerName)->toBe('s3-restore-test-database-uuid');
expect($containerName)->toStartWith('s3-restore-');
});
test('S3 download directory is created correctly', function () {
$resourceUuid = 'test-database-uuid';
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
expect($downloadDir)->toBe('/tmp/s3-restore-test-database-uuid');
expect($downloadDir)->toStartWith('/tmp/s3-restore-');
});
test('cancelS3Download cleans up correctly', function () {
// Test that cleanup directory path is correct
$resourceUuid = 'test-database-uuid';
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
$containerName = "s3-restore-{$resourceUuid}";
expect($downloadDir)->toContain($resourceUuid);
expect($containerName)->toContain($resourceUuid);
});
test('S3 file path formats are handled correctly', function () {
$paths = [
'/backups/db.gz',
'backups/db.gz',
'/nested/path/to/backup.sql.gz',
'backup-2025-01-15.gz',
];
foreach ($paths as $path) {
$cleanPath = ltrim($path, '/');
expect($cleanPath)->not->toStartWith('/');
}
});
test('formatBytes helper formats file sizes correctly', function () {
// Test various file sizes
expect(formatBytes(0))->toBe('0 B');
@@ -1,80 +0,0 @@
<?php
use App\Jobs\CoolifyTask;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\ScheduledTaskJob;
use App\Livewire\Project\Database\BackupEdit;
use Livewire\Attributes\Validate;
it('CoolifyTask has correct retry properties defined', function () {
$reflection = new ReflectionClass(CoolifyTask::class);
// Check public properties exist
expect($reflection->hasProperty('tries'))->toBeTrue()
->and($reflection->hasProperty('maxExceptions'))->toBeTrue()
->and($reflection->hasProperty('timeout'))->toBeTrue()
->and($reflection->hasMethod('backoff'))->toBeTrue();
// Get default values from class definition
$defaultProperties = $reflection->getDefaultProperties();
expect($defaultProperties['tries'])->toBe(3)
->and($defaultProperties['maxExceptions'])->toBe(1)
->and($defaultProperties['timeout'])->toBe(600);
});
it('ScheduledTaskJob has correct retry properties defined', function () {
$reflection = new ReflectionClass(ScheduledTaskJob::class);
// Check public properties exist
expect($reflection->hasProperty('tries'))->toBeTrue()
->and($reflection->hasProperty('maxExceptions'))->toBeTrue()
->and($reflection->hasProperty('timeout'))->toBeTrue()
->and($reflection->hasMethod('backoff'))->toBeTrue()
->and($reflection->hasMethod('failed'))->toBeTrue();
// Get default values from class definition
$defaultProperties = $reflection->getDefaultProperties();
expect($defaultProperties['tries'])->toBe(3)
->and($defaultProperties['maxExceptions'])->toBe(1)
->and($defaultProperties['timeout'])->toBe(300);
});
it('DatabaseBackupJob has correct retry properties defined', function () {
$reflection = new ReflectionClass(DatabaseBackupJob::class);
// Backups run a single attempt (retries were removed on purpose in 49a3bb0da):
// no $tries override and no backoff schedule, but a failed() handler is kept.
expect($reflection->hasProperty('tries'))->toBeFalse()
->and($reflection->hasMethod('backoff'))->toBeFalse()
->and($reflection->hasProperty('maxExceptions'))->toBeTrue()
->and($reflection->hasProperty('timeout'))->toBeTrue()
->and($reflection->hasMethod('failed'))->toBeTrue();
// Get default values from class definition
$defaultProperties = $reflection->getDefaultProperties();
expect($defaultProperties['maxExceptions'])->toBe(1)
->and($defaultProperties['timeout'])->toBe(3600);
});
it('DatabaseBackupJob enforces minimum timeout of 60 seconds', function () {
// The minimum is enforced where the backup timeout is set: the UI and the API
$timeoutProperty = new ReflectionProperty(BackupEdit::class, 'timeout');
$validateAttributes = $timeoutProperty->getAttributes(Validate::class);
expect($validateAttributes)->toHaveCount(1)
->and($validateAttributes[0]->getArguments()[0])->toContain('min:60');
$apiController = file_get_contents(__DIR__.'/../../app/Http/Controllers/Api/DatabasesController.php');
expect(substr_count($apiController, "'timeout' => 'integer|min:60|max:36000'"))->toBeGreaterThanOrEqual(2);
// The job falls back to the default timeout when none is stored
$reflection = new ReflectionClass(DatabaseBackupJob::class);
$constructor = $reflection->getMethod('__construct');
$source = file($reflection->getFileName());
$constructorSource = implode('', array_slice($source, $constructor->getStartLine() - 1, $constructor->getEndLine() - $constructor->getStartLine() + 1));
expect($constructorSource)->toContain('$this->timeout = $backup->timeout ?? 3600;');
});
@@ -1,96 +0,0 @@
<?php
use App\Jobs\ScheduledTaskJob;
use Illuminate\Support\Facades\Log;
beforeEach(function () {
// Mock Log facade to prevent actual logging during tests
Log::spy();
});
it('has executionId property for timeout handling', function () {
$reflection = new ReflectionClass(ScheduledTaskJob::class);
// Verify executionId property exists
expect($reflection->hasProperty('executionId'))->toBeTrue();
// Verify it's protected (will be serialized with the job)
$property = $reflection->getProperty('executionId');
expect($property->isProtected())->toBeTrue();
});
it('has failed method that handles job failures', function () {
$reflection = new ReflectionClass(ScheduledTaskJob::class);
// Verify failed() method exists
expect($reflection->hasMethod('failed'))->toBeTrue();
// Verify it accepts a Throwable parameter
$method = $reflection->getMethod('failed');
$parameters = $method->getParameters();
expect($parameters)->toHaveCount(1);
expect($parameters[0]->getName())->toBe('exception');
expect($parameters[0]->allowsNull())->toBeTrue();
});
it('failed method implementation reloads execution from database', function () {
// Read the failed() method source code to verify it reloads from database
$reflection = new ReflectionClass(ScheduledTaskJob::class);
$method = $reflection->getMethod('failed');
// Get the file and method source
$filename = $reflection->getFileName();
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$source = file($filename);
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
// Verify the implementation includes reloading from database
expect($methodSource)
->toContain('$this->executionId')
->toContain('ScheduledTaskExecution::find')
->toContain('ScheduledTaskExecution::query')
->toContain('scheduled_task_id')
->toContain('orderBy')
->toContain('status')
->toContain('failed')
->toContain('notify');
});
it('failed method updates execution with error_details field', function () {
// Read the failed() method source code to verify error_details is populated
$reflection = new ReflectionClass(ScheduledTaskJob::class);
$method = $reflection->getMethod('failed');
// Get the file and method source
$filename = $reflection->getFileName();
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$source = file($filename);
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
// Verify the implementation populates error_details field
expect($methodSource)->toContain('error_details');
});
it('failed method logs when execution cannot be found', function () {
// Read the failed() method source code to verify defensive logging
$reflection = new ReflectionClass(ScheduledTaskJob::class);
$method = $reflection->getMethod('failed');
// Get the file and method source
$filename = $reflection->getFileName();
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$source = file($filename);
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
// Verify the implementation logs a warning if execution is not found
expect($methodSource)
->toContain('Could not find execution log')
->toContain('warning');
});
@@ -1,121 +0,0 @@
<?php
use Illuminate\Support\Carbon;
afterEach(function () {
Carbon::setTestNow();
});
it('does not mutate Carbon instance when using copy() before subSeconds()', function () {
// This test verifies the fix for the bug where subSeconds() was mutating executionTime
$originalTime = Carbon::parse('2024-12-02 12:00:00');
$originalTimeString = $originalTime->toDateTimeString();
// Simulate what happens in processServerTasks() with the FIX applied
$waitTime = 360;
$threshold = $originalTime->copy()->subSeconds($waitTime);
// The original time should remain unchanged after using copy()
expect($originalTime->toDateTimeString())->toBe($originalTimeString);
expect($threshold->toDateTimeString())->toBe('2024-12-02 11:54:00');
});
it('demonstrates mutation bug when not using copy()', function () {
// This test shows what would happen WITHOUT the fix (the bug)
$originalTime = Carbon::parse('2024-12-02 12:00:00');
// Simulate what would happen WITHOUT copy() (the bug)
$waitTime = 360;
$threshold = $originalTime->subSeconds($waitTime);
// Without copy(), the original time is mutated!
expect($originalTime->toDateTimeString())->toBe('2024-12-02 11:54:00');
expect($threshold->toDateTimeString())->toBe('2024-12-02 11:54:00');
expect($originalTime)->toBe($threshold); // They're the same object
});
it('preserves executionTime across multiple subSeconds calls with copy()', function () {
// Simulate processing multiple servers with different wait times
$executionTime = Carbon::parse('2024-12-02 12:00:00');
$originalTimeString = $executionTime->toDateTimeString();
// Server 1: waitTime = 360s
$threshold1 = $executionTime->copy()->subSeconds(360);
expect($executionTime->toDateTimeString())->toBe($originalTimeString);
expect($threshold1->toDateTimeString())->toBe('2024-12-02 11:54:00');
// Server 2: waitTime = 300s (should still use original time)
$threshold2 = $executionTime->copy()->subSeconds(300);
expect($executionTime->toDateTimeString())->toBe($originalTimeString);
expect($threshold2->toDateTimeString())->toBe('2024-12-02 11:55:00');
// Server 3: waitTime = 360s (should still use original time)
$threshold3 = $executionTime->copy()->subSeconds(360);
expect($executionTime->toDateTimeString())->toBe($originalTimeString);
expect($threshold3->toDateTimeString())->toBe('2024-12-02 11:54:00');
// Server 4: waitTime = 300s (should still use original time)
$threshold4 = $executionTime->copy()->subSeconds(300);
expect($executionTime->toDateTimeString())->toBe($originalTimeString);
expect($threshold4->toDateTimeString())->toBe('2024-12-02 11:55:00');
// Server 5: waitTime = 360s (should still use original time)
$threshold5 = $executionTime->copy()->subSeconds(360);
expect($executionTime->toDateTimeString())->toBe($originalTimeString);
expect($threshold5->toDateTimeString())->toBe('2024-12-02 11:54:00');
// The executionTime should STILL be exactly the original time
expect($executionTime->toDateTimeString())->toBe('2024-12-02 12:00:00');
});
it('demonstrates compounding bug without copy() across multiple calls', function () {
// This shows the compounding bug that happens WITHOUT the fix
$executionTime = Carbon::parse('2024-12-02 12:00:00');
// Server 1: waitTime = 360s
$threshold1 = $executionTime->subSeconds(360);
expect($executionTime->toDateTimeString())->toBe('2024-12-02 11:54:00'); // MUTATED!
// Server 2: waitTime = 300s (uses already-mutated time)
$threshold2 = $executionTime->subSeconds(300);
expect($executionTime->toDateTimeString())->toBe('2024-12-02 11:49:00'); // Further mutated!
// Server 3: waitTime = 360s (uses even more mutated time)
$threshold3 = $executionTime->subSeconds(360);
expect($executionTime->toDateTimeString())->toBe('2024-12-02 11:43:00'); // Even more mutated!
// Server 4: waitTime = 300s
$threshold4 = $executionTime->subSeconds(300);
expect($executionTime->toDateTimeString())->toBe('2024-12-02 11:38:00');
// Server 5: waitTime = 360s
$threshold5 = $executionTime->subSeconds(360);
expect($executionTime->toDateTimeString())->toBe('2024-12-02 11:32:00');
// The executionTime is now 1680 seconds (28 minutes) earlier than it should be!
expect($executionTime->diffInSeconds(Carbon::parse('2024-12-02 12:00:00')))->toEqual(1680);
});
it('respects server timezone when evaluating cron schedules', function () {
// This test verifies that timezone parameter affects cron evaluation
// Set a fixed test time at 23:00 UTC
Carbon::setTestNow('2024-12-02 23:00:00', 'UTC');
$executionTime = Carbon::now();
$cronExpression = new \Cron\CronExpression('0 23 * * *'); // Every day at 11 PM
// Test 1: UTC timezone at 23:00 - should match
$timeInUTC = $executionTime->copy()->setTimezone('UTC');
expect($cronExpression->isDue($timeInUTC))->toBeTrue();
// Test 2: America/New_York timezone - 23:00 UTC is 18:00 EST, should not match 23:00 cron
$timeInEST = $executionTime->copy()->setTimezone('America/New_York');
expect($cronExpression->isDue($timeInEST))->toBeFalse();
// Test 3: Asia/Tokyo timezone - 23:00 UTC is 08:00 JST next day, should not match 23:00 cron
$timeInJST = $executionTime->copy()->setTimezone('Asia/Tokyo');
expect($cronExpression->isDue($timeInJST))->toBeFalse();
// Test 4: Verify copy() preserves the original time
expect($executionTime->toDateTimeString())->toBe('2024-12-02 23:00:00');
});
-53
View File
@@ -1,53 +0,0 @@
<?php
use App\Models\Application;
use App\Models\Server;
/**
* Test the Application::serverStatus() accessor
*
* This accessor determines if the underlying server infrastructure is functional.
* It should check Server::isFunctional() for the main server and all additional servers.
* It should NOT be affected by container/application health status (e.g., degraded:unhealthy).
*
* The bug that was fixed: Previously, it checked pivot.status and returned false
* when any additional server had status != 'running', including 'degraded:unhealthy'.
* This caused false "server has problems" warnings when the server was fine but
* containers were unhealthy.
*/
it('checks server infrastructure health not container status', function () {
// This is a documentation test to explain the fix
// The serverStatus accessor should:
// 1. Check if main server is functional (Server::isFunctional())
// 2. Check if each additional server is functional (Server::isFunctional())
// 3. NOT check pivot.status (that's application/container status, not server status)
//
// Before fix: Checked pivot.status !== 'running', causing false positives
// After fix: Only checks Server::isFunctional() for infrastructure health
expect(true)->toBeTrue();
})->note('The serverStatus accessor now correctly checks only server infrastructure health, not container status');
it('has correct logic in serverStatus accessor', function () {
// Read the actual code to verify the fix
$reflection = new ReflectionClass(Application::class);
$source = file_get_contents($reflection->getFileName());
// Extract just the serverStatus accessor method
preg_match('/protected function serverStatus\(\): Attribute\s*\{.*?^\s{4}\}/ms', $source, $matches);
$serverStatusCode = $matches[0] ?? '';
expect($serverStatusCode)->not->toBeEmpty('serverStatus accessor should exist');
// Check that the new logic exists (checks isFunctional on each server)
expect($serverStatusCode)
->toContain('$main_server_functional = $this->destination?->server?->isFunctional()')
->toContain('foreach ($this->additional_servers as $server)')
->toContain('if (! $server->isFunctional())');
// Check that the old buggy logic is removed from serverStatus accessor
expect($serverStatusCode)
->not->toContain('pluck(\'pivot.status\')')
->not->toContain('str($status)->before(\':\')')
->not->toContain('if ($server_status !== \'running\')');
})->note('Verifies that the serverStatus accessor uses the correct logic');
@@ -1,14 +0,0 @@
<?php
it('publishes the service environment file atomically', function () {
$source = file_get_contents(__DIR__.'/../../app/Models/Service.php');
$methodStart = strpos($source, 'public function saveComposeConfigs()');
$methodEnd = strpos($source, 'public function parse(', $methodStart);
$method = substr($source, $methodStart, $methodEnd - $methodStart);
expect($method)
->not->toContain("'rm -f .env || true'")
->toContain("new_public_id().'.env.tmp'")
->toContain('tee {$environmentFilename} > /dev/null && mv {$environmentFilename} .env')
->toContain('touch {$environmentFilename} && mv {$environmentFilename} .env');
});
@@ -1,36 +0,0 @@
<?php
/**
* Unit tests to verify that Configuration component properly listens to
* refresh events dispatched when compose file or domain changes.
*
* These tests verify the fix for the issue where changes to compose or domain
* were not visible until manual page refresh.
*/
it('ensures Configuration component listens to refreshServices event', function () {
$configurationFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/Configuration.php');
// Check that the Configuration component has refreshServices listener
expect($configurationFile)
->toContain("'refreshServices' => 'refreshServices'")
->toContain("'refresh' => 'refreshServices'");
});
it('ensures Configuration component has refreshServices method', function () {
$configurationFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/Configuration.php');
// Check that the refreshServices method exists
expect($configurationFile)
->toContain('public function refreshServices()')
->toContain('$this->service->refresh()')
->toContain('$this->applications = $this->service->applications->sort()')
->toContain('$this->databases = $this->service->databases->sort()');
});
it('ensures StackForm dispatches refreshServices event on submit', function () {
$stackFormFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/StackForm.php');
// Check that StackForm dispatches refreshServices event
expect($stackFormFile)
->toContain("->dispatch('refreshServices')");
});
@@ -1,69 +0,0 @@
<?php
/**
* Unit tests to verify that Docker Compose environment variables
* do not overwrite user-saved values on redeploy.
*
* Regression test for GitHub issues #8885 and #9136.
*/
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// The serviceParser function should use firstOrCreate (not updateOrCreate)
// for simple variable references like DATABASE_URL: ${DATABASE_URL}
// This is the key === parsedValue branch
expect($parsersFile)->toContain(
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
" // Ensure the variable exists in DB for .env generation and UI display\n".
' $resource->environment_variables()->firstOrCreate('
);
});
it('does not set value to null for simple variable references in serviceParser', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// The old bug: $value = null followed by updateOrCreate with 'value' => $value
// This pattern should NOT exist for simple variable references
expect($parsersFile)->not->toContain(
"\$value = null;\n".
' $resource->environment_variables()->updateOrCreate('
);
});
it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// In the balanced brace extraction path, simple variable references without defaults
// should use firstOrCreate to preserve user-saved values
// This appears twice (applicationParser and serviceParser)
$count = substr_count(
$parsersFile,
"// Simple variable reference without default\n".
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
' $envVar = $resource->environment_variables()->firstOrCreate('
);
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
});
it('does not replace self-referencing variable values in the environment array', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR}
// reference replaced with the DB value in the compose environment section.
// Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time.
// This prevents stale values when users update env vars without re-parsing compose.
expect($parsersFile)->toContain('Keep the ${VAR} reference in compose');
expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;');
});
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value
// This overwrites user-saved values with null on every deploy
expect($parsersFile)->not->toContain(
"\$value = null;\n".
' $resource->environment_variables()->updateOrCreate('
);
});
@@ -1,58 +0,0 @@
<?php
/**
* Unit tests to verify that service parser correctly handles image updates
* without creating duplicate ServiceApplication or ServiceDatabase records.
*
* These tests verify the fix for the issue where changing an image in a
* docker-compose file would create a new service instead of updating the existing one.
*/
it('ensures service parser does not include image in trusted service creation query', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that trusted creation only uses name and service_id
// and does not include image in the creation payload
expect($parsersFile)
->toContain("\$databaseFound = ServiceDatabase::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
->toContain("\$applicationFound = ServiceApplication::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
->toContain("create([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
->not->toContain("create([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
});
it('ensures service parser updates image after finding or creating service', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that image update logic exists after the trusted create/find branch
expect($parsersFile)
->toContain('// Update image if it changed')
->toContain('if ($savedService->image !== $image) {')
->toContain('$savedService->image = $image;')
->toContain('$savedService->save();');
});
it('ensures parseDockerComposeFile does not create duplicates on null savedService', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Check that the duplicate creation logic after is_null check has been fixed
// The old code would create a duplicate if savedService was null
// The new code checks for null within the else block and creates only if needed
expect($sharedFile)
->toContain('if (is_null($savedService)) {')
->toContain('$savedService = ServiceDatabase::create([')
->toContain('$savedService = ServiceApplication::create([');
});
it('verifies image update logic is present in parseDockerComposeFile', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Verify the image update logic exists
expect($sharedFile)
->toContain('// Check if image changed')
->toContain('if ($savedService->image !== $image) {')
->toContain('$savedService->image = $image;')
->toContain('$savedService->save();');
});
@@ -1,150 +0,0 @@
<?php
/**
* Unit tests to verify that serviceParser() correctly handles path appending
* to prevent duplication when SERVICE_URL/SERVICE_FQDN variables have path values.
*
* This tests the fix for GitHub issue #7363 where paths like /v1/realtime
* were being duplicated on subsequent parse() calls after FQDN updates.
*/
test('path is added when FQDN does not already contain it', function () {
$fqdn = 'https://test.abc';
$path = '/v1/realtime';
// Simulate the logic in serviceParser()
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
});
test('path is not added when FQDN already contains it', function () {
$fqdn = 'https://test.abc/v1/realtime';
$path = '/v1/realtime';
// Simulate the logic in serviceParser()
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
});
test('multiple parse calls with same path do not cause duplication', function () {
$fqdn = 'https://test.abc';
$path = '/v1/realtime';
// First parse (initial creation)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
// Second parse (after FQDN update)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
// Third parse (another update)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/v1/realtime');
});
test('different paths for different services work correctly', function () {
// Appwrite main service (/)
$fqdn1 = 'https://test.abc';
$path1 = '/';
if ($path1 !== '/' && ! str($fqdn1)->endsWith($path1)) {
$fqdn1 = "$fqdn1$path1";
}
expect($fqdn1)->toBe('https://test.abc');
// Appwrite console (/console)
$fqdn2 = 'https://test.abc';
$path2 = '/console';
if ($path2 !== '/' && ! str($fqdn2)->endsWith($path2)) {
$fqdn2 = "$fqdn2$path2";
}
expect($fqdn2)->toBe('https://test.abc/console');
// Appwrite realtime (/v1/realtime)
$fqdn3 = 'https://test.abc';
$path3 = '/v1/realtime';
if ($path3 !== '/' && ! str($fqdn3)->endsWith($path3)) {
$fqdn3 = "$fqdn3$path3";
}
expect($fqdn3)->toBe('https://test.abc/v1/realtime');
});
test('nested paths are handled correctly', function () {
$fqdn = 'https://test.abc';
$path = '/api/v1/endpoint';
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api/v1/endpoint');
// Second call should not duplicate
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api/v1/endpoint');
});
test('MindsDB /api path is handled correctly', function () {
$fqdn = 'https://test.abc';
$path = '/api';
// First parse
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api');
// Second parse should not duplicate
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
expect($fqdn)->toBe('https://test.abc/api');
});
test('fqdnValueForEnv path handling works correctly', function () {
$fqdnValueForEnv = 'test.abc';
$path = '/v1/realtime';
// First append
if (! str($fqdnValueForEnv)->endsWith($path)) {
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime');
// Second attempt should not duplicate
if (! str($fqdnValueForEnv)->endsWith($path)) {
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime');
});
test('url path handling works correctly', function () {
$url = 'https://test.abc';
$path = '/v1/realtime';
// First append
if (! str($url)->endsWith($path)) {
$url = "$url$path";
}
expect($url)->toBe('https://test.abc/v1/realtime');
// Second attempt should not duplicate
if (! str($url)->endsWith($path)) {
$url = "$url$path";
}
expect($url)->toBe('https://test.abc/v1/realtime');
});
@@ -53,83 +53,6 @@ it('detects port suffix using numeric check (correct logic)', function () {
}
});
it('shows current underscore-counting logic fails for some patterns', function () {
// This demonstrates the CURRENT BROKEN logic: substr_count === 3
$testCases = [
// [variable_name, underscore_count, should_detect_port]
// Works correctly with current logic (3 underscores total)
['SERVICE_URL_APP_3000', 3, true], // 3 underscores ✓
['SERVICE_URL_API_8080', 3, true], // 3 underscores ✓
// FAILS: 4 underscores (two-word service + port) - current logic says no port
['SERVICE_URL_MY_API_8080', 4, true], // 4 underscores ✗
['SERVICE_URL_WEB_APP_3000', 4, true], // 4 underscores ✗
// FAILS: 5+ underscores (three-word service + port) - current logic says no port
['SERVICE_URL_REDIS_CACHE_SERVER_6379', 5, true], // 5 underscores ✗
['SERVICE_URL_MY_LONG_APP_8080', 5, true], // 5 underscores ✗
// Works correctly (no port, not 3 underscores)
['SERVICE_URL_MY_APP', 3, false], // 3 underscores but non-numeric ✓
['SERVICE_URL_APP', 2, false], // 2 underscores ✓
];
foreach ($testCases as [$varName, $expectedUnderscoreCount, $shouldDetectPort]) {
$key = str($varName);
// Current logic: count underscores
$underscoreCount = substr_count($key->value(), '_');
expect($underscoreCount)->toBe($expectedUnderscoreCount, "Underscore count for $varName");
$currentLogicDetectsPort = ($underscoreCount === 3);
// Correct logic: check if numeric
$lastSegment = $key->afterLast('_')->value();
$correctLogicDetectsPort = is_numeric($lastSegment);
expect($correctLogicDetectsPort)->toBe($shouldDetectPort, "Correct logic should detect port for $varName");
// Show the discrepancy where current logic fails
if ($currentLogicDetectsPort !== $correctLogicDetectsPort) {
// This is a known bug - current logic is wrong
expect($currentLogicDetectsPort)->not->toBe($correctLogicDetectsPort, "Bug confirmed: current logic wrong for $varName");
}
}
});
it('generates correct URL with port suffix', function () {
// Test that URLs are correctly formatted with port appended
$testCases = [
['http://umami-abc123.domain.com', '3000', 'http://umami-abc123.domain.com:3000'],
['http://api-xyz789.domain.com', '8080', 'http://api-xyz789.domain.com:8080'],
['https://db-server.example.com', '5432', 'https://db-server.example.com:5432'],
['http://app.local', '80', 'http://app.local:80'],
];
foreach ($testCases as [$baseUrl, $port, $expectedUrlWithPort]) {
$urlWithPort = "$baseUrl:$port";
expect($urlWithPort)->toBe($expectedUrlWithPort);
}
});
it('generates correct FQDN with port suffix', function () {
// Test that FQDNs are correctly formatted with port appended
$testCases = [
['umami-abc123.domain.com', '3000', 'umami-abc123.domain.com:3000'],
['postgres-xyz789.domain.com', '5432', 'postgres-xyz789.domain.com:5432'],
['redis-cache.example.com', '6379', 'redis-cache.example.com:6379'],
];
foreach ($testCases as [$baseFqdn, $port, $expectedFqdnWithPort]) {
$fqdnWithPort = "$baseFqdn:$port";
expect($fqdnWithPort)->toBe($expectedFqdnWithPort);
}
});
it('correctly identifies service name with various patterns', function () {
// Test service name extraction with different patterns
@@ -1,178 +1,5 @@
<?php
/**
* Unit tests to verify that SERVICE_URL_* and SERVICE_FQDN_* variables
* with port suffixes are properly handled and populated.
*
* These variables should include the port number in both the key name and the URL value.
* Example: SERVICE_URL_UMAMI_3000 should be populated with http://domain.com:3000
*/
it('ensures parsers.php populates port-specific SERVICE variables', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that the fix is in place
$hasPortSpecificComment = str_contains($parsersFile, 'For port-specific variables');
$usesFqdnWithPort = str_contains($parsersFile, '$fqdnWithPort');
$usesUrlWithPort = str_contains($parsersFile, '$urlWithPort');
expect($hasPortSpecificComment)->toBeTrue('Should have comment about port-specific variables');
expect($usesFqdnWithPort)->toBeTrue('Should use $fqdnWithPort for port variables');
expect($usesUrlWithPort)->toBeTrue('Should use $urlWithPort for port variables');
});
it('verifies SERVICE_URL variable naming convention', function () {
// Test the naming convention for port-specific variables
// Base variable (no port): SERVICE_URL_UMAMI
$baseKey = 'SERVICE_URL_UMAMI';
expect(substr_count($baseKey, '_'))->toBe(2);
// Port-specific variable: SERVICE_URL_UMAMI_3000
$portKey = 'SERVICE_URL_UMAMI_3000';
expect(substr_count($portKey, '_'))->toBe(3);
// Extract service name
$serviceName = str($portKey)->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
expect($serviceName)->toBe('umami');
// Extract port
$port = str($portKey)->afterLast('_')->value();
expect($port)->toBe('3000');
});
it('verifies SERVICE_FQDN variable naming convention', function () {
// Test the naming convention for port-specific FQDN variables
// Base variable (no port): SERVICE_FQDN_POSTGRES
$baseKey = 'SERVICE_FQDN_POSTGRES';
expect(substr_count($baseKey, '_'))->toBe(2);
// Port-specific variable: SERVICE_FQDN_POSTGRES_5432
$portKey = 'SERVICE_FQDN_POSTGRES_5432';
expect(substr_count($portKey, '_'))->toBe(3);
// Extract service name
$serviceName = str($portKey)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
expect($serviceName)->toBe('postgres');
// Extract port
$port = str($portKey)->afterLast('_')->value();
expect($port)->toBe('5432');
});
it('verifies URL with port format', function () {
// Test that URLs with ports are formatted correctly
$baseUrl = 'http://umami-abc123.domain.com';
$port = '3000';
$urlWithPort = "$baseUrl:$port";
expect($urlWithPort)->toBe('http://umami-abc123.domain.com:3000');
expect($urlWithPort)->toContain(':3000');
});
it('verifies FQDN with port format', function () {
// Test that FQDNs with ports are formatted correctly
$baseFqdn = 'postgres-xyz789.domain.com';
$port = '5432';
$fqdnWithPort = "$baseFqdn:$port";
expect($fqdnWithPort)->toBe('postgres-xyz789.domain.com:5432');
expect($fqdnWithPort)->toContain(':5432');
});
it('verifies port extraction from variable name', function () {
// Test extracting port from various variable names
$tests = [
'SERVICE_URL_APP_3000' => '3000',
'SERVICE_URL_API_8080' => '8080',
'SERVICE_FQDN_DB_5432' => '5432',
'SERVICE_FQDN_REDIS_6379' => '6379',
];
foreach ($tests as $varName => $expectedPort) {
$port = str($varName)->afterLast('_')->value();
expect($port)->toBe($expectedPort, "Port extraction failed for $varName");
}
});
it('verifies service name extraction with port suffix', function () {
// Test extracting service name when port is present
$tests = [
'SERVICE_URL_APP_3000' => 'app',
'SERVICE_URL_MY_API_8080' => 'my_api',
'SERVICE_FQDN_DB_5432' => 'db',
'SERVICE_FQDN_REDIS_CACHE_6379' => 'redis_cache',
];
foreach ($tests as $varName => $expectedService) {
if (str($varName)->startsWith('SERVICE_URL_')) {
$serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
} else {
$serviceName = str($varName)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
}
expect($serviceName)->toBe($expectedService, "Service name extraction failed for $varName");
}
});
it('verifies distinction between base and port-specific variables', function () {
// Test that base and port-specific variables are different
$baseUrl = 'SERVICE_URL_UMAMI';
$portUrl = 'SERVICE_URL_UMAMI_3000';
expect($baseUrl)->not->toBe($portUrl);
expect(substr_count($baseUrl, '_'))->toBe(2);
expect(substr_count($portUrl, '_'))->toBe(3);
// Port-specific should contain port number
expect(str($portUrl)->contains('_3000'))->toBeTrue();
expect(str($baseUrl)->contains('_3000'))->toBeFalse();
});
it('verifies multiple port variables for same service', function () {
// Test that a service can have multiple port-specific variables
$service = 'api';
$ports = ['3000', '8080', '9090'];
foreach ($ports as $port) {
$varName = "SERVICE_URL_API_$port";
// Should have 3 underscores
expect(substr_count($varName, '_'))->toBe(3);
// Should extract correct service name
$serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
expect($serviceName)->toBe('api');
// Should extract correct port
$extractedPort = str($varName)->afterLast('_')->value();
expect($extractedPort)->toBe($port);
}
});
it('verifies common port numbers are handled correctly', function () {
// Test common port numbers used in applications
$commonPorts = [
'80' => 'HTTP',
'443' => 'HTTPS',
'3000' => 'Node.js/React',
'5432' => 'PostgreSQL',
'6379' => 'Redis',
'8080' => 'Alternative HTTP',
'9000' => 'PHP-FPM',
];
foreach ($commonPorts as $port => $description) {
$varName = "SERVICE_URL_APP_$port";
expect(substr_count($varName, '_'))->toBe(3, "Failed for $description port $port");
$extractedPort = str($varName)->afterLast('_')->value();
expect($extractedPort)->toBe((string) $port, "Port extraction failed for $description");
}
});
it('detects port-specific variables with numeric suffix', function () {
// Test that variables ending with a numeric port are detected correctly
// This tests the logic: if last segment after _ is numeric, it's a port
@@ -1,33 +0,0 @@
<?php
/**
* Runtime Logs must query Docker with the label that matches the resource type.
* Applications use coolify.applicationId, services coolify.serviceId, databases coolify.databaseId.
* Using the application helper for services/databases returns empty results even when containers run.
*/
it('looks up containers with the correct helper for each resource type', function () {
$logsFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Shared/Logs.php');
expect($logsFile)
->toContain('getCurrentApplicationContainerStatus(')
->toContain('getCurrentServiceContainerStatus(')
->toContain('getCurrentDatabaseContainerStatus(')
->toContain('$this->resource instanceof Application')
->toContain('$this->resource instanceof Service');
});
it('filters application containers by coolify.applicationId', function () {
$dockerHelpers = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
expect($dockerHelpers)->toContain('label=coolify.applicationId=');
});
it('filters service and database containers by the correct coolify labels', function () {
$dockerHelpers = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
expect($dockerHelpers)
->toContain('function getCurrentServiceContainerStatus')
->toContain('label=coolify.serviceId=')
->toContain('function getCurrentDatabaseContainerStatus')
->toContain('label=coolify.databaseId=');
});
-9
View File
@@ -80,15 +80,6 @@ it('strips non-numeric characters from server port on write', function () {
// escapeshellarg() in generated SSH commands (source-level verification)
// -------------------------------------------------------------------------
it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue();
$method = $reflection->getMethod('escapedUserAtHost');
expect($method->isPrivate())->toBeTrue();
expect($method->isStatic())->toBeTrue();
});
it('wraps port with escapeshellarg in getCommonSshOptions', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
-15
View File
@@ -190,19 +190,4 @@ class SshKeyValidationTest extends TestCase
// Lock file should persist (not be deleted) to prevent flock race conditions
$this->assertFileExists($lockFile, 'Lock file should persist after storeInFileSystem');
}
public function test_server_model_detects_private_key_id_changes()
{
$reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted');
$filename = $reflection->getFileName();
$startLine = $reflection->getStartLine();
$endLine = $reflection->getEndLine();
$source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
$this->assertStringContainsString(
"wasChanged('private_key_id')",
$source,
'Server saved event should detect private_key_id changes'
);
}
}
@@ -15,14 +15,6 @@ use Tests\TestCase;
*/
class SshMultiplexingDisableTest extends TestCase
{
public function test_generate_ssh_command_method_exists()
{
$this->assertTrue(
method_exists(SshMultiplexingHelper::class, 'generateSshCommand'),
'generateSshCommand method should exist'
);
}
public function test_remote_shell_prefers_bash_and_falls_back_to_sh()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand');
-10
View File
@@ -8,16 +8,6 @@ use Tests\TestCase;
class SshRetryMechanismTest extends TestCase
{
public function test_ssh_retry_handler_exists()
{
$this->assertTrue(class_exists(SshRetryHandler::class));
}
public function test_ssh_retryable_trait_exists()
{
$this->assertTrue(trait_exists(SshRetryable::class));
}
public function test_retry_on_ssh_connection_errors()
{
$handler = new class
-87
View File
@@ -1,87 +0,0 @@
<?php
// Test the proxy restart container cleanup logic
it('ensures container cleanup includes wait loop in command sequence', function () {
// This test verifies that the StartProxy action includes proper container
// cleanup with a wait loop to prevent "container name already in use" errors
// Simulate the command generation pattern from StartProxy
$commands = collect([
'mkdir -p /data/coolify/proxy/dynamic',
'cd /data/coolify/proxy',
"echo 'Creating required Docker Compose file.'",
"echo 'Pulling docker image.'",
'docker compose pull',
'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
" echo 'Stopping and removing existing coolify-proxy.'",
' docker stop coolify-proxy 2>/dev/null || true',
' docker rm -f coolify-proxy 2>/dev/null || true',
' # Wait for container to be fully removed',
' for i in {1..10}; do',
' if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
' break',
' fi',
' echo "Waiting for coolify-proxy to be removed... ($i/10)"',
' sleep 1',
' done',
" echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi',
"echo 'Starting coolify-proxy.'",
'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'",
]);
$commandsString = $commands->implode("\n");
// Verify the cleanup sequence includes all required components
expect($commandsString)->toContain('docker stop coolify-proxy 2>/dev/null || true')
->and($commandsString)->toContain('docker rm -f coolify-proxy 2>/dev/null || true')
->and($commandsString)->toContain('for i in {1..10}; do')
->and($commandsString)->toContain('if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then')
->and($commandsString)->toContain('break')
->and($commandsString)->toContain('sleep 1')
->and($commandsString)->toContain('docker compose up -d --wait --remove-orphans');
// Verify the order: cleanup must come before compose up
$stopPosition = strpos($commandsString, 'docker stop coolify-proxy');
$waitLoopPosition = strpos($commandsString, 'for i in {1..10}');
$composeUpPosition = strpos($commandsString, 'docker compose up -d');
expect($stopPosition)->toBeLessThan($waitLoopPosition)
->and($waitLoopPosition)->toBeLessThan($composeUpPosition);
});
it('includes error suppression in container cleanup commands', function () {
// Test that cleanup commands suppress errors to prevent failures
// when the container doesn't exist
$cleanupCommands = [
' docker stop coolify-proxy 2>/dev/null || true',
' docker rm -f coolify-proxy 2>/dev/null || true',
];
foreach ($cleanupCommands as $command) {
expect($command)->toContain('2>/dev/null || true');
}
});
it('waits up to 10 seconds for container removal', function () {
// Verify the wait loop has correct bounds
$waitLoop = [
' for i in {1..10}; do',
' if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
' break',
' fi',
' echo "Waiting for coolify-proxy to be removed... ($i/10)"',
' sleep 1',
' done',
];
$loopString = implode("\n", $waitLoop);
// Verify loop iterates 10 times
expect($loopString)->toContain('{1..10}')
->and($loopString)->toContain('sleep 1')
->and($loopString)->toContain('break'); // Early exit when container is gone
});

Some files were not shown because too many files have changed in this diff Show More