Merge remote-tracking branch 'origin/next' into fix/deploy-key-dedicated-path-race

This commit is contained in:
Andras Bacsai committed 2026-06-03 14:00:34 +02:00
commit 3eb5463e3e
108 files changed
+3323 -891

No files matched your search

@@ -0,0 +1,82 @@
<?php
use App\Actions\Application\StopApplication;
use App\Models\Application;
use App\Notifications\Application\RestartLimitReached;
function applicationWithRestartState(array $attributes = []): Application
{
$application = new Application;
$application->forceFill(array_merge([
'status' => 'exited:unhealthy',
'restart_count' => 2,
'max_restart_count' => 2,
'last_restart_type' => 'crash',
'last_restart_at' => now(),
], $attributes));
return $application;
}
it('detects applications stopped after reaching the crash restart limit', function () {
expect(applicationWithRestartState()->stoppedAfterRestartLimit())->toBeTrue()
->and(applicationWithRestartState(['status' => 'running:unhealthy'])->stoppedAfterRestartLimit())->toBeFalse()
->and(applicationWithRestartState(['restart_count' => 1])->stoppedAfterRestartLimit())->toBeFalse()
->and(applicationWithRestartState(['max_restart_count' => 0])->stoppedAfterRestartLimit())->toBeFalse()
->and(applicationWithRestartState(['last_restart_type' => null])->stoppedAfterRestartLimit())->toBeFalse();
});
it('shows a stopped after restart limit warning in the status badge', function () {
$html = view('components.status.index', [
'resource' => applicationWithRestartState(),
'showRefreshButton' => false,
])->render();
expect($html)->toContain('Stopped after reaching restart limit (2/2).')
->and($html)->toContain('Container has crashed and Coolify stopped it after 2 restart attempts.');
});
it('does not show the restart limit warning for a normal manual stop', function () {
$html = view('components.status.index', [
'resource' => applicationWithRestartState([
'restart_count' => 0,
'last_restart_type' => null,
]),
'showRefreshButton' => false,
])->render();
expect($html)->not->toContain('Stopped after reaching restart limit');
});
it('keeps restart tracking configurable when stopping an application', function () {
$method = new ReflectionMethod(StopApplication::class, 'handle');
$resetRestartCount = collect($method->getParameters())->firstWhere('name', 'resetRestartCount');
expect($resetRestartCount)->not->toBeNull()
->and($resetRestartCount->getDefaultValue())->toBeTrue();
});
it('uses the application link for restart limit notifications', function () {
$application = new class extends Application
{
public function link()
{
return 'https://coolify.test/project/link-from-model';
}
};
$application->forceFill([
'name' => 'crashy-app',
'uuid' => 'application-uuid',
'restart_count' => 2,
'max_restart_count' => 2,
]);
$application->setRelation('environment', (object) [
'uuid' => 'environment-uuid',
'name' => 'production',
'project' => (object) ['uuid' => 'project-uuid'],
]);
$notification = new RestartLimitReached($application);
expect($notification->resource_url)->toBe('https://coolify.test/project/link-from-model');
});
@@ -733,6 +733,7 @@ describe('custom_docker_run_options validation', function () {
'--entrypoint "sh -c \'npm start\'"',
'--entrypoint "sh -c \'php artisan schedule:work\'"',
'--hostname "my-host"',
'--dns 10.0.0.10 --dns=1.1.1.1',
]);
});
@@ -46,6 +46,24 @@ test('ConvertIp', function () {
]);
});
test('ConvertDns', function () {
$input = '--dns 10.0.0.10 --dns=1.1.1.1';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'dns' => ['10.0.0.10', '1.1.1.1'],
]);
});
test('ConvertDnsWithOtherOptions', function () {
$input = '--cap-add=NET_ADMIN --dns 10.0.0.10 --init';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'cap_add' => ['NET_ADMIN'],
'dns' => ['10.0.0.10'],
'init' => true,
]);
});
test('ConvertPrivilegedAndInit', function () {
$input = '---privileged --init';
$output = convertDockerRunToCompose($input);
@@ -29,3 +29,28 @@ it('uses sans font for the developer bulk environment variable editor', function
->not->toContain('wire:model="variables" monospace')
->not->toContain('wire:model="variablesPreview" monospace');
});
it('renders the environment variable search field above the production title', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect(strpos($view, 'aria-label="Search environment variables"'))
->toBeLessThan(strpos($view, '<h3>Production Environment Variables</h3>'));
});
it('renders a single no results message for empty environment variable searches', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)')
->toContain('<div>No environment variables found.</div>')
->toContain('@else');
});
it('only renders the production section when production variables are visible', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())')
->not->toContain('@forelse ($this->environmentVariables as $env)')
->not->toContain('@empty');
});
@@ -0,0 +1,251 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Service;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->actingAs($this->user);
});
it('filters production environment variables by key case-insensitively', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
});
it('treats production environment variable search wildcards literally', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'APIXKEY',
'value' => 'other-secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'PERCENT%KEY',
'value' => 'percent-secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api_key');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
$component->set('search', '%KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['PERCENT%KEY']);
});
it('filters preview environment variables by key case-insensitively', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'PREVIEW_TOKEN',
'value' => 'preview-secret',
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'OTHER_PREVIEW_VALUE',
'value' => 'preview-other',
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'token');
expect($component->instance()->environmentVariablesPreview->pluck('key')->all())
->toBe(['PREVIEW_TOKEN']);
});
it('filters hardcoded Docker Compose environment variables by key case-insensitively', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
API_TOKEN: hardcoded-secret
DATABASE_URL: postgres://example
YAML,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->set('search', 'api');
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
->toBe(['API_TOKEN']);
});
it('does not show the empty production message when search only matches hardcoded variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
API_TOKEN: hardcoded-secret
DATABASE_URL: postgres://example
YAML,
]);
Livewire::test(All::class, ['resource' => $service])
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertSee('API_TOKEN')
->assertDontSee('No environment variables found.');
});
it('keeps developer view unfiltered after searching', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->call('switch')
->assertSet('view', 'dev');
expect($component->get('variables'))
->toContain('API_KEY=secret')
->toContain('DATABASE_URL=postgres://example');
});
it('does not delete non-matching variables when saving developer view after searching', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->call('switch')
->call('submit');
expect($application->environment_variables()->pluck('key')->all())
->toContain('API_KEY')
->toContain('DATABASE_URL');
});
it('hides the preview section when search filters out all preview variables', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$application->environment_variables_preview()->where('key', 'API_KEY')->delete();
EnvironmentVariable::create([
'key' => 'PREVIEW_TOKEN',
'value' => 'preview-secret',
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertSee('API_KEY')
->assertDontSee('Preview Deployments Environment Variables')
->assertDontSee('PREVIEW_TOKEN');
});
@@ -0,0 +1,136 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GithubApp;
use App\Models\PrivateKey;
function applicationWithGitSettings(bool $shallow = true): Application
{
$application = new Application;
$application->forceFill([
'uuid' => 'test-app-uuid',
'git_repository' => 'coollabsio/private-app',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = $shallow;
$settings->is_git_submodules_enabled = false;
$settings->is_git_lfs_enabled = false;
$application->setRelation('settings', $settings);
return $application;
}
it('uses http 1 transport for public https source clones', function () {
$application = applicationWithGitSettings();
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => true,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 clone --depth=1 -b 'main' 'https://github.com/coollabsio/private-app' '/artifacts/test-deployment'")
->not->toContain('Primary repository import failed, retrying with HTTP/1.1')
->not->toContain('mktemp')
->not->toContain('git_retry_dir');
});
it('applies http 1 transport to https fetches after clone', function () {
$application = applicationWithGitSettings();
$application->git_commit_sha = 'abc123def456abc123def456abc123def456abc1';
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => true,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 fetch --depth=1 origin 'abc123def456abc123def456abc123def456abc1'")
->toContain("git -c http.version=HTTP/1.1 -c advice.detachedHead=false checkout 'abc123def456abc123def456abc123def456abc1'");
});
it('does not add http transport config to ssh deploy key clones', function () {
$application = applicationWithGitSettings();
$application->private_key_id = 1;
$application->setRelation('private_key', new class extends PrivateKey
{
public function getAttribute($key)
{
if ($key === 'private_key') {
return 'fake-private-key';
}
return parent::getAttribute($key);
}
});
$application->git_repository = 'git@github.com:coollabsio/private-app.git';
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->not->toContain('http.version=HTTP/1.1')
->not->toContain('Primary repository import failed, retrying with HTTP/1.1');
});
it('supports dedicated checkout directories for compose file loading', function () {
$application = applicationWithGitSettings();
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => true,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
only_checkout: true,
exec_in_docker: false,
custom_base_dir: 'checkout',
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 clone --depth=1 --no-checkout -b 'main' 'https://github.com/coollabsio/private-app' 'checkout'")
->not->toContain('mktemp')
->not->toContain('git_retry_dir');
});
it('applies http 1 transport to custom bitbucket pull request checkout', function () {
$application = applicationWithGitSettings();
$application->git_repository = 'https://bitbucket.org/coollabsio/private-app.git';
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
pull_request_id: 123,
git_type: 'bitbucket',
exec_in_docker: false,
commit: 'abc123def456abc123def456abc123def456abc1',
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 checkout 'abc123def456abc123def456abc123def456abc1'");
});
+124
View File
@@ -7,6 +7,8 @@ use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -84,6 +86,67 @@ describe('GitHub Source Change Component', function () {
->assertSet('privateKeyId', null);
});
test('creates one-time states for manifest conversion and installation callbacks', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
$component = Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful();
$manifestState = $component->get('manifestState');
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($manifestState)->not->toBeEmpty()
->and($installState)->not->toBeEmpty()
->and($installState)->not->toBe($manifestState)
->and($installationUrl)->not->toContain($githubApp->uuid)
->and(Cache::get('github-app-setup-state:'.hash('sha256', $manifestState)))
->toMatchArray([
'action' => 'manifest',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
])
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
]);
});
test('installation path is generated from the provided github app instance', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'id' => 123,
'name' => 'Provided GitHub App',
'html_url' => 'https://github.example.com',
'team_id' => 456,
]);
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($installationUrl)->toStartWith('https://github.example.com/github-apps/provided-git-hub-app/installations/new?')
->and($installState)->not->toBeEmpty()
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => 123,
'team_id' => 456,
]);
});
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
config(['app.url' => 'http://localhost:8000']);
@@ -305,4 +368,65 @@ describe('GitHub Source Change Component', function () {
return str_contains($message, 'Private Key not found');
});
});
test('checkPermissions syncs refetched permissions into input fields', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'contents' => null,
'metadata' => null,
'pull_requests' => null,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.com/app' => Http::response([
'permissions' => [
'contents' => 'read',
'metadata' => 'read',
'pull_requests' => 'write',
],
]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('name', 'test-git-hub-app')
->assertSet('contents', null)
->assertSet('metadata', null)
->assertSet('pullRequests', null)
->call('checkPermissions')
->assertDispatched('success')
->assertSet('name', 'test-git-hub-app')
->assertSet('contents', 'read')
->assertSet('metadata', 'read')
->assertSet('pullRequests', 'write');
$githubApp->refresh();
expect($githubApp->contents)->toBe('read')
->and($githubApp->metadata)->toBe('read')
->and($githubApp->pull_requests)->toBe('write');
});
});
@@ -0,0 +1,21 @@
<?php
it('keeps sentinel restarted events from re-syncing editable form fields', function () {
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
preg_match('/public function handleSentinelRestarted\([^)]*\)\s*\{(?<body>.*?)\n \}/s', $componentSource, $matches);
expect($matches['body'] ?? '')
->toContain('$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;')
->not->toContain('$this->syncData();');
});
it('dispatches a server navbar refresh after toggling sentinel', function () {
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
preg_match('/public function toggleSentinel\([^)]*\).*?\{(?<body>.*?)
\}/s', $componentSource, $matches);
expect($matches['body'] ?? '')
->toContain("\$this->dispatch('refreshServerShow');");
});
+39
View File
@@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Str;
function bladeView(string $path): string
{
return file_get_contents(base_path($path));
}
it('guards deployment log clipboard writes and reports promise failures', function () {
$view = bladeView('resources/views/livewire/project/application/deployment/show.blade.php');
expect($view)
->toContain('copyLogs()')
->toContain('navigator.clipboard?.writeText')
->toContain("Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);")
->toContain("Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);")
->toContain("Livewire.dispatch('success', ['Logs copied to clipboard.']);");
expect(Str::between($view, 'copyLogs() {', 'downloadLogs()'))
->toContain('navigator.clipboard?.writeText(content).then(() =>')
->not->toContain("navigator.clipboard.writeText(content);\n Livewire.dispatch('success'");
});
it('guards shared log clipboard writes and handles Livewire preparation failures', function () {
$view = bladeView('resources/views/livewire/project/shared/get-logs.blade.php');
expect($view)
->toContain('navigator.clipboard?.writeText')
->toContain("Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);")
->toContain("Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);")
->toContain("Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);")
->toContain("Livewire.dispatch('success', ['Logs copied to clipboard.']);");
expect($view)
->toContain('$wire.copyLogs().then(logs =>')
->toContain('}).catch(() => {')
->not->toContain('navigator.clipboard.writeText(logs);');
});
+2 -2
View File
@@ -30,7 +30,7 @@ it('logs in an existing user when the oauth provider returns a mixed-case email'
'email' => 'username@example.edu',
]);
$provider = \Mockery::mock();
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
@@ -58,7 +58,7 @@ it('rejects oauth logins when the provider does not return an email address', fu
'is_registration_enabled' => true,
]);
$provider = \Mockery::mock();
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
@@ -2,9 +2,15 @@
use App\Livewire\Settings\ScheduledJobs;
use App\Models\DockerCleanupExecution;
use App\Models\Environment;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use App\Services\SchedulerLogParser;
@@ -13,6 +19,35 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
function withIsolatedScheduledLogsForMonitoringTest(callable $callback): mixed
{
$logDir = storage_path('logs');
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$renamed = [];
foreach (glob($logDir.'/scheduled-*.log') as $log) {
$tmp = $log.'.scheduled-jobs-test-bak';
rename($log, $tmp);
$renamed[$tmp] = $log;
}
try {
return $callback($logDir.'/scheduled-'.now()->format('Y-m-d').'.log');
} finally {
foreach (glob($logDir.'/scheduled-*.log') as $log) {
@unlink($log);
}
foreach ($renamed as $tmp => $original) {
if (file_exists($tmp)) {
rename($tmp, $original);
}
}
}
}
beforeEach(function () {
// Create root team (id 0) and root user
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
@@ -270,3 +305,96 @@ test('skipped jobs show fallback when resource is deleted', function () {
rename($tmp, $original);
}
});
test('skipped service database backups render with service backup link', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->rootTeam->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
]);
$serviceDatabase = ServiceDatabase::create([
'service_id' => $service->id,
'name' => 'service-postgres',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => $serviceDatabase->id,
'database_type' => $serviceDatabase->getMorphClass(),
'enabled' => true,
]);
withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $service, $serviceDatabase) {
file_put_contents(
$logPath,
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n"
);
$expectedUrl = route('project.service.database.backups', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $serviceDatabase->uuid,
]);
Livewire::test(ScheduledJobs::class)
->assertOk()
->assertSee('service-postgres')
->assertSeeHtml('href="'.$expectedUrl.'"');
});
});
test('skipped standalone database backups keep standalone backup link', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->rootTeam->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'standalone-postgres',
'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(),
]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'enabled' => true,
]);
withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $database) {
file_put_contents(
$logPath,
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n"
);
$expectedUrl = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
Livewire::test(ScheduledJobs::class)
->assertOk()
->assertSee('standalone-postgres')
->assertSeeHtml('href="'.$expectedUrl.'"');
});
});
@@ -199,8 +199,9 @@ it('rejects replayed github app manifest states', function () {
it('requires authentication before processing github app install callbacks', function () {
Http::preventStrayRequests();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect();
Http::assertNothingSent();
@@ -209,22 +210,110 @@ it('requires authentication before processing github app install callbacks', fun
expect($this->githubApp->installation_id)->toBeNull();
});
it('rejects github app install callbacks for an unknown github app', function () {
it('rejects github app install callbacks with an app uuid as state', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertNotFound();
Http::assertNothingSent();
});
it('redirects browser github app install callbacks with missing or expired state to sources', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=install&installation_id=123456')
->assertRedirect(route('source.all'));
$this->get('/webhooks/source/github/install?state=expired-state&setup_action=install&installation_id=123456')
->assertRedirect(route('source.all'));
Http::assertNothingSent();
});
it('rejects github app setup states for the wrong callback action', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
cacheGithubAppSetupState('manifest-state', 'manifest', $this->githubApp);
cacheGithubAppSetupState('install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=manifest-state&setup_action=install&installation_id=123456')
->assertNotFound();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=install-state&code=real-code')
->assertNotFound();
Http::assertNothingSent();
});
it('allows github app install callbacks for repository update setup actions', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=update&installation_id=111111')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(111111);
});
it('redirects github app repository update callbacks without a matching source to the sources page', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=update&installation_id=123456')
->assertRedirect(route('source.all'));
Http::assertNothingSent();
});
it('rejects github app install callbacks for unknown setup actions', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=remove&installation_id=123456')
->assertUnprocessable();
Http::assertNothingSent();
});
it('rejects github app setup states from another team', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$otherTeam = Team::factory()->create();
$otherGithubApp = GithubApp::create([
'name' => 'Other GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $otherTeam->id,
'is_system_wide' => false,
]);
cacheGithubAppSetupState('other-team-state', 'manifest', $otherGithubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=other-team-state&code=real-code')
->assertForbidden();
Http::assertNothingSent();
});
it('rejects an installation id that github does not confirm belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerificationFailure();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=999999')
->assertForbidden();
$this->githubApp->refresh();
@@ -235,21 +324,39 @@ it('sets installation id when github confirms it belongs to the app', function (
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('rejects replayed github app install states', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertNotFound();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('allows reinstalling an already configured github app installation id', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=222222')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
@@ -0,0 +1,130 @@
<?php
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->oldPrivateKey = createServerUpdatePrivateKeyApiKey($this->team, 'Old Key');
$this->newPrivateKey = createServerUpdatePrivateKeyApiKey($this->team, 'New Key');
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->oldPrivateKey->id,
]);
$token = $this->user->createToken('write-token', ['write']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->bearerToken = $token->plainTextToken;
});
function createServerUpdatePrivateKeyApiKey(Team $team, string $name): PrivateKey
{
return PrivateKey::create([
'name' => $name,
'private_key' => generateSSHKey('ed25519')['private'],
'team_id' => $team->id,
]);
}
function patchServerUpdatePrivateKeyApi(object $test, Server $server, string $bearerToken, array $payload): TestResponse
{
return $test->withHeaders([
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, $payload);
}
it('updates the server private key from private_key_uuid', function () {
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'private_key_uuid' => $this->newPrivateKey->uuid,
])->assertCreated()
->assertJson(['uuid' => $this->server->uuid]);
expect($this->server->fresh()->private_key_id)->toBe($this->newPrivateKey->id);
});
it('returns not found for an unknown private_key_uuid and leaves the key unchanged', function () {
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'private_key_uuid' => 'unknown-private-key-uuid',
])->assertNotFound()
->assertJson(['message' => 'Private key not found.']);
expect($this->server->fresh()->private_key_id)->toBe($this->oldPrivateKey->id);
});
it('does not allow attaching a private key from another team', function () {
$otherTeam = Team::factory()->create();
$otherTeamPrivateKey = createServerUpdatePrivateKeyApiKey($otherTeam, 'Other Team Key');
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'private_key_uuid' => $otherTeamPrivateKey->uuid,
])->assertNotFound()
->assertJson(['message' => 'Private key not found.']);
expect($this->server->fresh()->private_key_id)->toBe($this->oldPrivateKey->id);
});
it('keeps the existing private key when private_key_uuid is omitted', function () {
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'name' => 'Renamed Server',
])->assertCreated()
->assertJson(['uuid' => $this->server->uuid]);
$server = $this->server->fresh();
expect($server->name)->toBe('Renamed Server')
->and($server->private_key_id)->toBe($this->oldPrivateKey->id);
});
it('can disable build server mode via API', function () {
$this->server->settings()->update(['is_build_server' => true]);
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'is_build_server' => false,
])->assertCreated()
->assertJson(['uuid' => $this->server->uuid]);
expect($this->server->settings->fresh()->is_build_server)->toBeFalse();
});
it('rejects an invalid disk usage check frequency without partially updating the server', function () {
$this->server->proxy->set('type', 'TRAEFIK');
$this->server->save();
$this->server->settings()->update(['is_build_server' => false]);
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'name' => 'Renamed Server',
'is_build_server' => true,
'proxy_type' => 'none',
'server_disk_usage_check_frequency' => 'not a valid schedule',
])->assertUnprocessable()
->assertJson([
'message' => 'Validation failed.',
'errors' => [
'server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.'],
],
]);
$server = $this->server->fresh();
expect($server->name)->not->toBe('Renamed Server')
->and($server->settings->is_build_server)->toBeFalse()
->and($server->proxy->get('type'))->toBe('TRAEFIK');
});
@@ -0,0 +1,182 @@
<?php
use App\Livewire\Boarding\Index as BoardingIndex;
use App\Livewire\Server\New\ByIp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
'uuid' => (string) new Cuid2,
'name' => 'Test SSH Key',
'description' => 'Test SSH Key',
'private_key' => 'test-private-key',
'team_id' => $this->team->id,
]));
$token = $this->user->createToken('write-token', ['write']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->token = $token->plainTextToken;
});
it('creates a server through the API with a dotted SSH username', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Dotted User Server',
'ip' => '192.0.2.10',
'private_key_uuid' => $this->privateKey->uuid,
'user' => 'deploy.user',
]);
$response->assertCreated();
$this->assertDatabaseHas('servers', [
'ip' => '192.0.2.10',
'user' => 'deploy.user',
]);
});
it('updates a server through the API with a dotted SSH username', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'user' => 'deploy',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, [
'user' => 'deploy.user',
]);
$response->assertStatus(201);
expect($server->fresh()->user)->toBe('deploy.user');
});
it('rejects unsafe SSH usernames when creating a server through the API', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Unsafe User Server',
'ip' => '192.0.2.11',
'private_key_uuid' => $this->privateKey->uuid,
'user' => 'deploy$user',
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.user.0', 'The User may only contain letters, numbers, dots, hyphens, and underscores.');
});
it('rejects unsafe SSH usernames through the API', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, [
'user' => 'deploy$user',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['user']]);
$response->assertJsonPath('errors.user.0', 'The User may only contain letters, numbers, dots, hyphens, and underscores.');
});
it('allows dotted SSH usernames in the server creation form', function () {
$this->actingAs($this->user);
Livewire::test(ByIp::class, [
'private_keys' => collect([$this->privateKey]),
'limit_reached' => false,
])
->set('name', 'Dotted User Server')
->set('ip', '192.0.2.20')
->set('user', 'deploy.user')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors(['user']);
$this->assertDatabaseHas('servers', [
'ip' => '192.0.2.20',
'user' => 'deploy.user',
]);
});
it('rejects unsafe SSH usernames in the server creation form', function () {
$this->actingAs($this->user);
Livewire::test(ByIp::class, [
'private_keys' => collect([$this->privateKey]),
'limit_reached' => false,
])
->set('name', 'Unsafe User Server')
->set('ip', '192.0.2.21')
->set('user', 'deploy$user')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasErrors(['user' => ['regex']]);
});
it('rejects unsafe SSH usernames during onboarding server creation', function () {
$this->actingAs($this->user);
Livewire::test(BoardingIndex::class)
->set('createdPrivateKey', $this->privateKey)
->set('remoteServerName', 'Unsafe User Server')
->set('remoteServerHost', '192.0.2.30')
->set('remoteServerPort', 22)
->set('remoteServerUser', 'deploy$user')
->call('saveServer')
->assertHasErrors([
'remoteServerUser' => [
'regex',
'The SSH User may only contain letters, numbers, dots, hyphens, and underscores.',
],
]);
});
it('rejects unsafe SSH usernames during onboarding server validation', function () {
$this->actingAs($this->user);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'user' => 'deploy',
]);
Livewire::test(BoardingIndex::class)
->set('createdServer', $server)
->set('remoteServerPort', 22)
->set('remoteServerUser', 'deploy$user')
->call('saveAndValidateServer')
->assertHasErrors([
'remoteServerUser' => [
'regex',
'The SSH User may only contain letters, numbers, dots, hyphens, and underscores.',
],
]);
});
@@ -1,5 +1,6 @@
<?php
use App\Livewire\Destination\Resources as DestinationResources;
use App\Livewire\Destination\Show as DestinationShow;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\DockerImage;
@@ -294,4 +295,80 @@ describe('Destination/Show team scope', function () {
expect($component->get('destination'))->toBeNull();
$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')
->assertDontSee('Search resources...')
->assertDontSee('No resources are using this destination.');
});
test('mount with own standalone destination lists deployed resources', function () {
Application::factory()->create([
'name' => 'application-on-destination',
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
Service::factory()->create([
'name' => 'service-on-destination',
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
StandalonePostgresql::withoutEvents(fn () => StandalonePostgresql::create([
'uuid' => fake()->uuid(),
'name' => 'database-on-destination',
'postgres_password' => 'password',
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]));
Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('Search resources...')
->assertSee('Project')
->assertSee('Environment')
->assertSee('Name')
->assertSee('Type')
->assertSee('application-on-destination')
->assertSee('service-on-destination')
->assertSee('database-on-destination')
->assertSee($this->projectA->name)
->assertSee($this->environmentA->name);
});
test('mount with own standalone destination shows empty state without resources', function () {
Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('No resources are using this destination.');
});
test('mount with own standalone destination does not list another team resources', function () {
Application::factory()->create([
'name' => 'other-team-application',
'environment_id' => $this->environmentB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => StandaloneDocker::class,
]);
Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertDontSee('other-team-application');
});
test('resource without project renders as non-clickable row', function () {
StandalonePostgresql::withoutEvents(fn () => StandalonePostgresql::create([
'uuid' => fake()->uuid(),
'name' => 'database-without-project',
'postgres_password' => 'password',
'environment_id' => null,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]));
$component = Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('database-without-project');
expect($component->html())->not->toContain('href=""');
});
});
+4 -4
View File
@@ -17,7 +17,7 @@ it('initializes latest version during mount from cached versions data', function
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn([
'coolify' => [
'v4' => [
@@ -42,7 +42,7 @@ it('falls back to 0.0.0 during mount when cached versions data is unavailable',
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn(null);
Livewire::test(Upgrade::class)
@@ -58,7 +58,7 @@ it('clears stale upgrade availability when current version already matches lates
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn([
'coolify' => [
'v4' => [
@@ -83,7 +83,7 @@ it('clears stale upgrade availability when current version is newer than cached
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn([
'coolify' => [
'v4' => [
@@ -42,8 +42,8 @@ namespace {
expect($result['commands'])
->not->toContain('git config --global')
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 clone --recurse-submodules -b 'main'")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule sync")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule update --init --recursive");
});
}
+63 -413
View File
@@ -1,427 +1,77 @@
<?php
/**
* Security tests for log viewer XSS prevention
*
* These tests verify that the log viewer components properly sanitize
* HTML content to prevent cross-site scripting (XSS) attacks.
*/
function renderedDataLineTextValue(string $logContent): string
{
$escapedContent = e($logContent);
$html = '<span data-line-text="'.$escapedContent.'">'.$escapedContent.'</span>';
$document = new DOMDocument;
$document->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
return $document->documentElement->getAttribute('data-line-text');
}
describe('Log Viewer HTML Tag Preservation', function () {
it('lets Blade escape deployment log data attributes only once', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php');
expect($view)
->toContain('data-log-content="{{ $searchableContent }}"')
->toContain('data-line-text="{{ $lineContent }}"')
->not->toContain('data-log-content="{{ htmlspecialchars($searchableContent) }}"')
->not->toContain('data-line-text="{{ htmlspecialchars($lineContent) }}"');
});
it('preserves literal html-like log text for client-side search reset and highlighting', function () {
$logContent = '<div>A</div>';
expect(renderedDataLineTextValue($logContent))->toBe($logContent);
});
it('does not strip tags with DOMParser based html decoding', function () {
$views = [
__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php',
__DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php',
];
foreach ($views as $view) {
expect(file_get_contents($view))
->not->toContain('decodeHtml(text)')
->not->toContain('DOMParser().parseFromString');
}
});
});
describe('Log Viewer XSS Prevention', function () {
it('escapes script tags in log output', function () {
it('keeps script-like log output as text in Blade rendered markup', function () {
$maliciousLog = '<script>alert("XSS")</script>';
$escaped = htmlspecialchars($maliciousLog);
$escapedLog = e($maliciousLog);
expect($escaped)->toContain('&lt;script&gt;');
expect($escaped)->not->toContain('<script>');
expect($escapedLog)
->toContain('&lt;script&gt;')
->not->toContain('<script>');
});
it('escapes event handler attributes', function () {
$maliciousLog = '<img src=x onerror="alert(\'XSS\')">';
$escaped = htmlspecialchars($maliciousLog);
it('keeps dangerous attributes as literal dataset text for textContent rendering', function () {
$maliciousLog = '<img src=x onerror="alert(1)">';
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('onerror');
expect($escaped)->not->toContain('<img');
expect($escaped)->not->toContain('onerror="alert');
expect(renderedDataLineTextValue($maliciousLog))->toBe($maliciousLog);
});
it('escapes javascript: protocol URLs', function () {
$maliciousLog = '<a href="javascript:alert(\'XSS\')">click</a>';
$escaped = htmlspecialchars($maliciousLog);
it('uses text nodes for search highlighting instead of injected html', function () {
$views = [
__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php',
__DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php',
];
expect($escaped)->toContain('&lt;a');
expect($escaped)->toContain('javascript:');
expect($escaped)->not->toContain('<a href=');
});
foreach ($views as $view) {
$contents = file_get_contents($view);
it('escapes data: URLs with scripts', function () {
$maliciousLog = '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;iframe');
expect($escaped)->toContain('data:');
expect($escaped)->not->toContain('<iframe');
});
it('escapes style-based XSS attempts', function () {
$maliciousLog = '<div style="background:url(\'javascript:alert(1)\')">test</div>';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;div');
expect($escaped)->toContain('style');
expect($escaped)->not->toContain('<div style=');
});
it('escapes Alpine.js directive injection', function () {
$maliciousLog = '<div x-html="alert(\'XSS\')">test</div>';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;div');
expect($escaped)->toContain('x-html');
expect($escaped)->not->toContain('<div x-html=');
});
it('escapes multiple HTML entities', function () {
$maliciousLog = '<>&"\'';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toBe('&lt;&gt;&amp;&quot;&#039;');
});
it('preserves legitimate text content', function () {
$legitimateLog = 'INFO: Application started successfully';
$escaped = htmlspecialchars($legitimateLog);
expect($escaped)->toBe($legitimateLog);
});
it('handles ANSI color codes after escaping', function () {
$logWithAnsi = "\e[31mERROR:\e[0m Something went wrong";
$escaped = htmlspecialchars($logWithAnsi);
// ANSI codes should be preserved in escaped form
expect($escaped)->toContain('ERROR');
expect($escaped)->toContain('Something went wrong');
});
it('escapes complex nested HTML structures', function () {
$maliciousLog = '<div onclick="alert(1)"><img src=x onerror="alert(2)"><script>alert(3)</script></div>';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;div');
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('&lt;script&gt;');
expect($escaped)->not->toContain('<div');
expect($escaped)->not->toContain('<img');
expect($escaped)->not->toContain('<script>');
});
});
/**
* Tests for x-text security approach
*
* These tests verify that using x-text instead of x-html eliminates XSS risks
* by rendering all content as plain text rather than HTML.
*/
describe('x-text Security', function () {
it('verifies x-text renders content as plain text, not HTML', function () {
// x-text always renders as textContent, never as innerHTML
// This means any HTML tags in the content are displayed as literal text
$contentWithHtml = '<script>alert("XSS")</script>';
$escaped = htmlspecialchars($contentWithHtml);
// When stored in data attribute and rendered with x-text:
// 1. Server escapes to: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;
// 2. Browser decodes the attribute value to: <script>alert("XSS")</script>
// 3. x-text renders it as textContent (plain text), NOT innerHTML
// 4. Result: User sees "<script>alert("XSS")</script>" as text, script never executes
expect($escaped)->toContain('&lt;script&gt;');
expect($escaped)->not->toContain('<script>');
});
it('confirms x-text prevents Alpine.js directive injection', function () {
$maliciousContent = '<div x-data="{ evil: true }" x-html="alert(1)">test</div>';
$escaped = htmlspecialchars($maliciousContent);
// Even if attacker includes Alpine directives in log content:
// 1. Server escapes them
// 2. x-text renders as plain text
// 3. Alpine never processes these as directives
// 4. User just sees the literal text
expect($escaped)->toContain('x-data');
expect($escaped)->toContain('x-html');
expect($escaped)->not->toContain('<div x-data=');
});
it('verifies x-text prevents event handler execution', function () {
$maliciousContent = '<img src=x onerror="alert(\'XSS\')">';
$escaped = htmlspecialchars($maliciousContent);
// With x-text approach:
// 1. Content is escaped on server
// 2. Rendered as textContent, not innerHTML
// 3. No HTML parsing means no event handlers
// 4. User sees the literal text, no image is rendered, no event fires
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('onerror');
expect($escaped)->not->toContain('<img');
});
it('verifies x-text prevents style-based attacks', function () {
$maliciousContent = '<style>body { display: none; }</style>';
$escaped = htmlspecialchars($maliciousContent);
// x-text renders everything as text:
// 1. Style tags never get parsed as HTML
// 2. CSS never gets applied
// 3. User just sees the literal style tag content
expect($escaped)->toContain('&lt;style&gt;');
expect($escaped)->not->toContain('<style>');
});
it('confirms CSS class-based highlighting is safe', function () {
// New approach uses CSS classes for highlighting instead of injecting HTML
// The 'log-highlight' class is applied via Alpine.js :class binding
// This is safe because:
// 1. Class names are controlled by JavaScript, not user input
// 2. No HTML injection occurs
// 3. CSS provides visual feedback without executing code
$highlightClass = 'log-highlight';
expect($highlightClass)->toBe('log-highlight');
expect($highlightClass)->not->toContain('<');
expect($highlightClass)->not->toContain('script');
});
it('verifies granular highlighting only marks matching text', function () {
// splitTextForHighlight() divides text into parts
// Only matching portions get highlight: true
// Each part is rendered with x-text (safe plain text)
// Highlight class applied only to matching spans
$logLine = 'ERROR: Database connection failed';
$searchQuery = 'ERROR';
// When searching for "ERROR":
// Part 1: { text: "ERROR", highlight: true } <- highlighted
// Part 2: { text: ": Database connection failed", highlight: false } <- not highlighted
// This ensures only the search term is highlighted, not the entire line
expect($logLine)->toContain($searchQuery);
expect(strlen($searchQuery))->toBeLessThan(strlen($logLine));
});
});
/**
* Integration documentation tests
*
* These tests document the expected flow of log sanitization with x-text
*/
describe('Log Sanitization Flow with x-text', function () {
it('documents the secure x-text rendering flow', function () {
$rawLog = '<script>alert("XSS")</script>';
// Step 1: Server-side escaping (PHP)
$escaped = htmlspecialchars($rawLog);
expect($escaped)->toBe('&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;');
// Step 2: Stored in data-log-content attribute
// <div data-log-content="&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;" x-text="getDisplayText($el.dataset.logContent)">
// Step 3: Client-side getDisplayText() decodes HTML entities
// const decoded = doc.documentElement.textContent;
// Result: '<script>alert("XSS")</script>' (as text string)
// Step 4: x-text renders as textContent (NOT innerHTML)
// Alpine.js sets element.textContent = decoded
// Result: Browser displays '<script>alert("XSS")</script>' as visible text
// The script tag is never parsed or executed - it's just text
// Step 5: Highlighting via CSS class
// If search query matches, 'log-highlight' class is added
// Visual feedback is provided through CSS, not HTML injection
});
it('documents search highlighting with CSS classes', function () {
$legitimateLog = '2024-01-01T12:00:00.000Z ERROR: Database connection failed';
// Server-side: Escape and store
$escaped = htmlspecialchars($legitimateLog);
expect($escaped)->toBe($legitimateLog); // No special chars
// Client-side: If user searches for "ERROR"
// 1. splitTextForHighlight() divides the text into parts:
// - Part 1: "2024-01-01T12:00:00.000Z " (highlight: false)
// - Part 2: "ERROR" (highlight: true) <- This part gets highlighted
// - Part 3: ": Database connection failed" (highlight: false)
// 2. Each part is rendered as a <span> with x-text (safe)
// 3. Only Part 2 gets the 'log-highlight' class via :class binding
// 4. CSS provides yellow/warning background color on "ERROR" only
// 5. No HTML injection occurs - just multiple safe text spans
expect($legitimateLog)->toContain('ERROR');
});
it('verifies no HTML injection occurs during search', function () {
$logWithHtml = 'User input: <img src=x onerror="alert(1)">';
$escaped = htmlspecialchars($logWithHtml);
// Even if log contains malicious HTML:
// 1. Server escapes it
// 2. x-text renders as plain text
// 3. Search highlighting uses CSS class, not HTML tags
// 4. User sees the literal text with highlight background
// 5. No script execution possible
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('onerror');
expect($escaped)->not->toContain('<img src=');
});
it('documents that user search queries cannot inject HTML', function () {
// User search query is only used in:
// 1. String matching (includes() check) - safe
// 2. CSS class application - safe (class name is hardcoded)
// 3. Match counting - safe (just text comparison)
// User query is NOT used in:
// 1. HTML generation - eliminated by switching to x-text
// 2. innerHTML assignment - x-text uses textContent only
// 3. DOM manipulation - only CSS classes are applied
$userSearchQuery = '<script>alert("XSS")</script>';
// The search query is used in matchesSearch() which does:
// line.toLowerCase().includes(this.searchQuery.toLowerCase())
// This is safe string comparison, no HTML parsing
expect($userSearchQuery)->toContain('<script>');
// But it's only used for string matching, never rendered as HTML
});
});
/**
* Tests for DoS prevention in HTML entity decoding
*
* These tests verify that the decodeHtml() function in the client-side JavaScript
* has proper safeguards against deeply nested HTML entities that could cause DoS.
*/
describe('HTML Entity Decoding DoS Prevention', function () {
it('documents the DoS vulnerability with unbounded decoding', function () {
// Without a max iteration limit, an attacker could provide deeply nested entities:
// &amp;amp;amp;amp;amp;amp;amp;amp;amp;amp; (10 levels deep)
// Each iteration decodes one level, causing excessive CPU usage
$normalEntity = '&amp;lt;script&gt;';
// Normal case: 2-3 iterations to fully decode
expect($normalEntity)->toContain('&amp;');
});
it('verifies max iteration limit prevents DoS', function () {
// The decodeHtml() function should have a maxIterations constant (e.g., 3)
// This ensures even with deeply nested entities, decoding stops after 3 iterations
// Preventing CPU exhaustion from malicious input
$deeplyNested = '&amp;amp;amp;amp;amp;amp;amp;amp;lt;';
// With max 3 iterations, only first 3 levels decoded
// Remaining nesting is preserved but doesn't cause DoS
// This test documents that the limit exists
expect(strlen($deeplyNested))->toBeGreaterThan(10);
});
it('documents normal use cases work within iteration limit', function () {
// Legitimate double-encoding (common in logs): &amp;lt;
// Iteration 1: &<
// Iteration 2: <
// Total: 2 iterations (well within limit of 3)
$doubleEncoded = '&amp;lt;script&amp;gt;';
expect($doubleEncoded)->toContain('&amp;');
// Triple-encoding (rare but possible): &amp;amp;lt;
// Iteration 1: &amp;<
// Iteration 2: &<
// Iteration 3: <
// Total: 3 iterations (exactly at limit)
$tripleEncoded = '&amp;amp;lt;div&amp;amp;gt;';
expect($tripleEncoded)->toContain('&amp;amp;');
});
it('documents that iteration limit is sufficient for real-world logs', function () {
// Analysis of real-world log encoding scenarios:
// 1. Single encoding: 1 iteration
// 2. Double encoding (logs passed through multiple systems): 2 iterations
// 3. Triple encoding (rare edge case): 3 iterations
// 4. Beyond triple encoding: Likely malicious or severely misconfigured
// The maxIterations = 3 provides:
// - Protection against DoS attacks
// - Support for all legitimate use cases
// - Predictable performance characteristics
expect(3)->toBeGreaterThanOrEqual(3); // Max iterations covers all legitimate cases
});
it('verifies decoding stops at max iterations even with malicious input', function () {
// With maxIterations = 3, decoding flow:
// Input: &amp;amp;amp;amp;amp; (5 levels)
// Iteration 1: &amp;amp;amp;amp;
// Iteration 2: &amp;amp;amp;
// Iteration 3: &amp;amp;
// Stop: Max iterations reached
// Output: &amp; (partially decoded, but safe from DoS)
$maliciousInput = str_repeat('&amp;', 10).'lt;script&gt;';
// Even with 10 levels of nesting, function stops at 3 iterations
expect(strlen($maliciousInput))->toBeGreaterThan(50);
// The point is NOT that we fully decode it, but that we don't loop forever
});
it('confirms while loop condition includes iteration check', function () {
// The vulnerable code was:
// while (decoded !== prev) { ... }
//
// The fixed code should be:
// while (decoded !== prev && iterations < maxIterations) { ... }
//
// This ensures the loop ALWAYS terminates after maxIterations
$condition = 'iterations < maxIterations';
expect($condition)->toContain('maxIterations');
expect($condition)->toContain('<');
});
it('documents performance impact of iteration limit', function () {
// Without limit:
// - Malicious input: 1000+ iterations, seconds of CPU time
// - DoS attack possible with relatively small payloads
//
// With limit (maxIterations = 3):
// - Malicious input: 3 iterations max, milliseconds of CPU time
// - DoS attack prevented, performance predictable
$maxIterations = 3;
$worstCaseOps = $maxIterations * 2; // DOMParser + textContent per iteration
expect($worstCaseOps)->toBeLessThan(10); // Very low computational cost
});
it('verifies iteration counter increments correctly', function () {
// The implementation should:
// 1. Initialize: let iterations = 0;
// 2. Check: while (... && iterations < maxIterations)
// 3. Increment: iterations++;
//
// This ensures the counter actually prevents infinite loops
$initialValue = 0;
$increment = 1;
$maxValue = 3;
expect($initialValue)->toBe(0);
expect($increment)->toBe(1);
expect($maxValue)->toBeGreaterThan($initialValue);
});
it('confirms fix addresses the security advisory correctly', function () {
// Security advisory states:
// "decodeHtml() function uses a loop that could be exploited with
// deeply nested HTML entities, potentially causing performance issues or DoS"
//
// Fix applied:
// 1. Add maxIterations constant (value: 3)
// 2. Add iterations counter
// 3. Update while condition to include iteration check
// 4. Increment counter in loop body
//
// This directly addresses the vulnerability
$vulnerabilityFixed = true;
expect($vulnerabilityFixed)->toBeTrue();
expect($contents)
->toContain('document.createTextNode')
->toContain('mark.textContent')
->not->toContain('innerHTML')
->not->toContain('x-html');
}
});
});
+7 -6
View File
@@ -1,6 +1,7 @@
<?php
use App\Actions\Proxy\GetProxyConfiguration;
use Illuminate\Log\LogManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Spatie\SchemalessAttributes\SchemalessAttributes;
@@ -83,7 +84,7 @@ YAML;
});
it('logs warning when regenerating defaults', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
// No DB config, no disk config — will try to regenerate
@@ -94,7 +95,7 @@ it('logs warning when regenerating defaults', function () {
// the force regenerate path instead
try {
GetProxyConfiguration::run($server, forceRegenerate: true);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// generateDefaultProxyConfiguration may fail without full server setup
}
@@ -115,7 +116,7 @@ it('does not read from disk when DB config exists', function () {
});
it('rejects stored Traefik config when proxy type is CADDY', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
@@ -126,7 +127,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
// Both will fail in test env, but the warning log proves mismatch was detected.
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
@@ -136,7 +137,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
});
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
@@ -144,7 +145,7 @@ it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
+19
View File
@@ -0,0 +1,19 @@
<?php
use App\Models\Server;
use App\Support\ValidationPatterns;
it('provides shared validation rules for SSH usernames', function () {
expect(ValidationPatterns::SERVER_USERNAME_PATTERN)->toBe('/^[a-zA-Z0-9._-]+$/');
expect(ValidationPatterns::serverUsernameRules())->toContain('regex:'.ValidationPatterns::SERVER_USERNAME_PATTERN);
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy.user'))->toBe(1);
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy$user'))->toBe(0);
});
it('preserves dots when sanitizing server SSH usernames', function () {
$server = new Server;
$server->user = 'deploy.user';
expect($server->user)->toBe('deploy.user');
});