mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 12:16:45 +00:00
Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber
This commit is contained in:
commit
b01c2dd52b
151 files changed
+5997
-1093
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'");
|
||||
});
|
||||
@@ -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,131 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
Config::set('app.maintenance.driver', 'file');
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('session.driver', 'array');
|
||||
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
function createInvitationLinkFixture(array $invitationAttributes = []): array
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$password = 'temporary-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => $invitationAttributes['email'] ?? 'invitee@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'force_password_reset' => true,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$uuid = (string) new Cuid2(32);
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
|
||||
$link = route('auth.link', ['token' => $token]);
|
||||
|
||||
$invitation = TeamInvitation::create(array_merge([
|
||||
'team_id' => $team->id,
|
||||
'uuid' => $uuid,
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => $link,
|
||||
'via' => 'link',
|
||||
], $invitationAttributes));
|
||||
|
||||
return [$team, $user, $password, $token, $invitation];
|
||||
}
|
||||
|
||||
it('accepts a valid magic link invitation only once and rotates the temporary password', function () {
|
||||
[$team, $user, $password, $token] = createInvitationLinkFixture();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
|
||||
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check($password, $user->password))->toBeFalse();
|
||||
|
||||
auth()->logout();
|
||||
session()->flush();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
it('rejects a magic link when the invitation was revoked', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$invitation->delete();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->teams()->where('personal_team', false)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a magic link when another invitation exists for the same email', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$invitation->delete();
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
TeamInvitation::create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'uuid' => (string) new Cuid2(32),
|
||||
'email' => $user->email,
|
||||
'role' => 'admin',
|
||||
'link' => url('/invitations/other-invitation'),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->teams()->where('team_id', $otherTeam->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a magic link when the invitation expired', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$invitation->forceFill([
|
||||
'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
|
||||
'updated_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
|
||||
])->save();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertDatabaseMissing('team_invitations', ['id' => $invitation->id]);
|
||||
});
|
||||
|
||||
it('rejects a malformed magic link token', function () {
|
||||
$this->get(route('auth.link', ['token' => 'not-a-valid-token']))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
@@ -4,8 +4,10 @@ use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
@@ -15,6 +17,10 @@ uses(RefreshDatabase::class);
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
Config::set('app.maintenance.driver', 'file');
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('session.driver', 'array');
|
||||
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
@@ -33,7 +39,16 @@ describe('invitation link login', function () {
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
$uuid = 'email-verification-test-invitation';
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
|
||||
TeamInvitation::create([
|
||||
'team_id' => $team->id,
|
||||
'uuid' => $uuid,
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => route('auth.link', ['token' => $token]),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
@@ -51,9 +66,19 @@ describe('invitation link login', function () {
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
$uuid = 'email-verification-login-test-invitation';
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
|
||||
TeamInvitation::create([
|
||||
'team_id' => $team->id,
|
||||
'uuid' => $uuid,
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => route('auth.link', ['token' => $token]),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
expect(auth()->id())->toBe($user->id);
|
||||
});
|
||||
|
||||
@@ -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');");
|
||||
});
|
||||
@@ -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);');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function reflectedLogDrainNetworkCommands(object $action, Server|Service $model): array
|
||||
{
|
||||
$method = new ReflectionMethod($action, 'logDrainNetworkConnectCommands');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($action, $model);
|
||||
}
|
||||
|
||||
function createServerWithTeam(): Server
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
|
||||
return Server::factory()->create(['team_id' => $team->id]);
|
||||
}
|
||||
|
||||
function createServiceOnServer(Server $server, string $network, bool $connectToDockerNetwork = true): Service
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$destination = StandaloneDocker::query()->firstOrCreate(
|
||||
['server_id' => $server->id, 'network' => $network],
|
||||
['uuid' => fake()->uuid(), 'name' => fake()->unique()->word()]
|
||||
);
|
||||
|
||||
return Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'connect_to_docker_network' => $connectToDockerNetwork,
|
||||
'docker_compose' => "services:\n signoz:\n image: signoz/signoz:latest\n",
|
||||
]);
|
||||
}
|
||||
|
||||
it('connects the log drain container to a service preferred network when the server log drain is enabled', function () {
|
||||
$server = createServerWithTeam();
|
||||
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
|
||||
$service = createServiceOnServer($server, 'signoz-net', true);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
|
||||
|
||||
expect($commands)->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true");
|
||||
});
|
||||
|
||||
it('does not connect the log drain container when service preferred network is disabled', function () {
|
||||
$server = createServerWithTeam();
|
||||
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
|
||||
$service = createServiceOnServer($server, 'signoz-net', false);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
|
||||
|
||||
expect($commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not connect the log drain container when the server log drain is disabled', function () {
|
||||
$server = createServerWithTeam();
|
||||
$service = createServiceOnServer($server, 'signoz-net', true);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
|
||||
|
||||
expect($commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('connects a restarted log drain container to all enabled service preferred networks on the server', function () {
|
||||
$server = createServerWithTeam();
|
||||
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
|
||||
createServiceOnServer($server, 'signoz-net', true);
|
||||
createServiceOnServer($server, 'ignored-net', false);
|
||||
createServiceOnServer($server, 'signoz-net', true);
|
||||
|
||||
$otherServer = createServerWithTeam();
|
||||
createServiceOnServer($otherServer, 'other-server-net', true);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartLogDrain, $server->fresh(['settings']));
|
||||
|
||||
expect($commands)
|
||||
->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true")
|
||||
->not->toContain("docker network connect 'ignored-net' coolify-log-drain >/dev/null 2>&1 || true")
|
||||
->not->toContain("docker network connect 'other-server-net' coolify-log-drain >/dev/null 2>&1 || true");
|
||||
|
||||
expect($commands)->toHaveCount(1);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('strips leftover x-cloak after wire:navigate to prevent blank page', function () {
|
||||
$appJs = file_get_contents(resource_path('js/app.js'));
|
||||
|
||||
expect($appJs)
|
||||
->toContain("document.addEventListener('livewire:navigated'")
|
||||
->toContain("querySelectorAll('[x-cloak]')")
|
||||
->toContain("removeAttribute('x-cloak')");
|
||||
});
|
||||
|
||||
it('keeps the initial-load x-cloak guard on the app wrapper', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)->toContain('x-cloak');
|
||||
});
|
||||
@@ -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) [
|
||||
|
||||
@@ -21,6 +21,12 @@ it('renders password input with Alpine-managed visibility state', function () {
|
||||
->not->toContain('changePasswordFieldType');
|
||||
});
|
||||
|
||||
it('renders password input before visibility toggle in tab order', function () {
|
||||
$html = Blade::render('<x-forms.input type="password" id="secret" />');
|
||||
|
||||
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
|
||||
});
|
||||
|
||||
it('renders password textarea with Alpine-managed visibility state', function () {
|
||||
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
|
||||
|
||||
@@ -31,6 +37,12 @@ it('renders password textarea with Alpine-managed visibility state', function ()
|
||||
->not->toContain('changePasswordFieldType');
|
||||
});
|
||||
|
||||
it('renders password textarea input before visibility toggle in tab order', function () {
|
||||
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
|
||||
|
||||
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
|
||||
});
|
||||
|
||||
it('renders textarea without monospace classes by default', function () {
|
||||
$html = Blade::render('<x-forms.textarea id="notes" />');
|
||||
|
||||
@@ -53,3 +65,9 @@ it('resets password visibility on success event for env-var-input', function ()
|
||||
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
|
||||
->toContain('x-bind:type="type"');
|
||||
});
|
||||
|
||||
it('renders env var password input before visibility toggle in tab order', function () {
|
||||
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
|
||||
|
||||
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function runPostgresUpgradeScriptCommand(string $script): array
|
||||
{
|
||||
$root = base_path();
|
||||
$workingDirectory = sys_get_temp_dir().'/coolify-postgres-upgrade-test-'.Str::random(12);
|
||||
$binDirectory = $workingDirectory.'/bin';
|
||||
|
||||
mkdir($binDirectory, 0777, true);
|
||||
|
||||
file_put_contents($binDirectory.'/docker', <<<'BASH'
|
||||
#!/bin/bash
|
||||
if [ "$1" = "inspect" ]; then
|
||||
printf '%s\n' "${MOCK_DOCKER_IMAGE}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "compose" ]; then
|
||||
printf '%s\n' "${LATEST_IMAGE}" > "${MOCK_LATEST_IMAGE_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
BASH);
|
||||
chmod($binDirectory.'/docker', 0755);
|
||||
|
||||
$outputFile = $workingDirectory.'/latest-image.txt';
|
||||
$command = sprintf(
|
||||
'PATH=%s:$PATH MOCK_LATEST_IMAGE_OUTPUT=%s COOLIFY_POSTGRES_UPGRADE_SOURCE_ONLY=true bash -c %s 2>&1',
|
||||
escapeshellarg($binDirectory),
|
||||
escapeshellarg($outputFile),
|
||||
escapeshellarg('cd '.escapeshellarg($root).'; '.$script),
|
||||
);
|
||||
|
||||
exec($command, $output, $exitCode);
|
||||
|
||||
return [$exitCode, implode("\n", $output), is_file($outputFile) ? trim(file_get_contents($outputFile)) : null];
|
||||
}
|
||||
|
||||
it('detects the current Coolify image tag from the running container image', function (string $scriptPath, string $image, string $expectedTag) {
|
||||
[$exitCode, $output] = runPostgresUpgradeScriptCommand(sprintf(
|
||||
'MOCK_DOCKER_IMAGE=%s; export MOCK_DOCKER_IMAGE; source %s; current_coolify_image_tag',
|
||||
escapeshellarg($image),
|
||||
escapeshellarg($scriptPath),
|
||||
));
|
||||
|
||||
expect($exitCode)->toBe(0)
|
||||
->and($output)->toBe($expectedTag);
|
||||
})->with([
|
||||
'nightly standard registry image' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
|
||||
'nightly registry with port' => ['other/nightly/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
|
||||
'nightly digest suffix' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
|
||||
'scripts standard registry image' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
|
||||
'scripts registry with port' => ['scripts/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
|
||||
'scripts digest suffix' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
|
||||
]);
|
||||
|
||||
it('passes the preserved Coolify image tag to docker compose when starting the stack', function (string $scriptPath) {
|
||||
[$exitCode, $output, $latestImage] = runPostgresUpgradeScriptCommand(
|
||||
sprintf('source %s; start_stack 4.0.0-beta.420', escapeshellarg($scriptPath)),
|
||||
);
|
||||
|
||||
expect($exitCode)->toBe(0)
|
||||
->and($output)->toBe('')
|
||||
->and($latestImage)->toBe('4.0.0-beta.420');
|
||||
})->with([
|
||||
'nightly script' => 'other/nightly/upgrade-postgres.sh',
|
||||
'release script' => 'scripts/upgrade-postgres.sh',
|
||||
]);
|
||||
@@ -21,6 +21,18 @@ it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server doub
|
||||
expect($event->onOneServer)->toBeTrue();
|
||||
});
|
||||
|
||||
it('schedules ssh mux cleanup locally on every scheduler host', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())->first(
|
||||
fn ($e) => (string) $e->description === 'cleanup:ssh-mux'
|
||||
);
|
||||
|
||||
expect($event)->not->toBeNull();
|
||||
expect($event->onOneServer)->toBeFalse();
|
||||
expect($event->getSummaryForDisplay())->toBe('cleanup:ssh-mux');
|
||||
});
|
||||
|
||||
it('schedules every production job with onOneServer', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\New\Select;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('returns the service templates bundle last updated timestamp', function () {
|
||||
$component = new Select;
|
||||
$templatePath = base_path('templates/'.config('constants.services.file_name'));
|
||||
|
||||
$resources = $component->loadServices();
|
||||
|
||||
expect($resources)
|
||||
->toHaveKey('serviceTemplatesLastUpdated')
|
||||
->and($resources['serviceTemplatesLastUpdated'])
|
||||
->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i'));
|
||||
});
|
||||
|
||||
it('returns each service template last updated timestamp', function () {
|
||||
$component = new Select;
|
||||
$templatePath = base_path('templates/compose/activepieces.yaml');
|
||||
|
||||
$resources = $component->loadServices();
|
||||
|
||||
expect($resources['services']['activepieces'])
|
||||
->toHaveKey('templateLastUpdated')
|
||||
->and($resources['services']['activepieces']['templateLastUpdated'])
|
||||
->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i'));
|
||||
});
|
||||
|
||||
it('uses a service template timestamp cache keyed by bundle mtime', function () {
|
||||
$bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name')));
|
||||
Cache::put("service-template-last-updated-map:{$bundleMtime}", [
|
||||
'activepieces' => 'Cached timestamp',
|
||||
], now()->addDay());
|
||||
|
||||
$resources = (new Select)->loadServices();
|
||||
|
||||
expect($resources['services']['activepieces']['templateLastUpdated'])->toBe('Cached timestamp');
|
||||
});
|
||||
|
||||
it('does not use stale service template timestamp cache entries from another bundle mtime', function () {
|
||||
$bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name')));
|
||||
Cache::put('service-template-last-updated-map:'.($bundleMtime - 1), [
|
||||
'activepieces' => 'Stale cached timestamp',
|
||||
], now()->addDay());
|
||||
|
||||
$resources = (new Select)->loadServices();
|
||||
|
||||
expect($resources['services']['activepieces']['templateLastUpdated'])->not->toBe('Stale cached timestamp');
|
||||
});
|
||||
|
||||
it('renders the service templates last updated hint placeholder', function () {
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
$view = $this->view('livewire.project.new.select', [
|
||||
'current_step' => 'type',
|
||||
'environments' => collect(),
|
||||
]);
|
||||
|
||||
$view->assertSee('Last Updated on Service Templates:');
|
||||
$view->assertSee('serviceTemplatesLastUpdated');
|
||||
$view->assertSee('service.templateLastUpdated');
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Jobs\CleanupStaleMultiplexedConnections;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* SSH multiplexing now relies on OpenSSH's native lazy ControlMaster handling.
|
||||
* Coolify should add mux options to real ssh/scp commands, but must not pre-warm
|
||||
* background masters with separate `ssh -fN` processes.
|
||||
* Tests for the explicit per-server mux lock that prevents concurrent workers
|
||||
* from racing on initial ControlMaster creation.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -20,12 +22,18 @@ function makeMuxServer(): Server
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
|
||||
$privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n".
|
||||
"QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n".
|
||||
"hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n".
|
||||
"AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n".
|
||||
"uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n".
|
||||
$privateKeyContent = '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
'.
|
||||
'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
'.
|
||||
'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
'.
|
||||
'hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
'.
|
||||
'AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
'.
|
||||
'uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
'.
|
||||
'-----END OPENSSH PRIVATE KEY-----';
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
@@ -37,87 +45,92 @@ function makeMuxServer(): Server
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
|
||||
|
||||
return Server::factory()->create([
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('does not prewarm a background ssh master', function () {
|
||||
it('establishes a master with ssh -fN and never the orphan-prone ssh -fNM', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake();
|
||||
Process::fake([
|
||||
'*-O check*' => Process::result(exitCode: 1),
|
||||
'*-fN *' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
|
||||
|
||||
Process::assertNothingRan();
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')
|
||||
&& ! str_contains($process->command, 'ssh -fNM'));
|
||||
});
|
||||
|
||||
it('adds native openssh multiplexing options to ssh commands', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
Process::fake();
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
|
||||
|
||||
expect($command)
|
||||
->toContain('-o ControlMaster=auto')
|
||||
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
|
||||
->toContain('-o ControlPersist=3600')
|
||||
->not->toContain('-O check')
|
||||
->not->toContain('ssh -fN');
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('can generate terminal ssh commands without a hard command timeout', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
|
||||
|
||||
expect($command)
|
||||
->toStartWith('ssh ')
|
||||
->not->toStartWith('timeout ')
|
||||
->not->toContain('timeout 3600 ssh');
|
||||
});
|
||||
|
||||
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true);
|
||||
|
||||
expect($command)
|
||||
->not->toContain('-o ControlMaster=auto')
|
||||
->not->toContain('-o ControlPath=')
|
||||
->not->toContain('-o ControlPersist=');
|
||||
});
|
||||
|
||||
it('adds native openssh multiplexing options to scp commands', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
it('reuses an existing healthy master without spawning a new one', function () {
|
||||
config([
|
||||
'constants.ssh.mux_enabled' => true,
|
||||
'constants.ssh.mux_health_check_enabled' => true,
|
||||
]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake();
|
||||
Process::fake([
|
||||
'*-O check*' => Process::result(exitCode: 0),
|
||||
'*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0),
|
||||
]);
|
||||
|
||||
$command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
|
||||
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
|
||||
|
||||
expect($command)
|
||||
->toContain('-o ControlMaster=auto')
|
||||
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
|
||||
->toContain('-o ControlPersist=3600')
|
||||
->not->toContain('-O check')
|
||||
->not->toContain('ssh -fN');
|
||||
|
||||
Process::assertNothingRan();
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN'));
|
||||
});
|
||||
|
||||
it('returns false and runs no process when multiplexing is globally disabled', function () {
|
||||
it('refreshes an expired master before reuse', function () {
|
||||
config([
|
||||
'constants.ssh.mux_enabled' => true,
|
||||
'constants.ssh.mux_health_check_enabled' => false,
|
||||
'constants.ssh.mux_max_age' => 10,
|
||||
]);
|
||||
$server = makeMuxServer();
|
||||
Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 30, 3600);
|
||||
|
||||
Process::fake([
|
||||
'*-O check*' => Process::result(exitCode: 0),
|
||||
'*-O exit*' => Process::result(exitCode: 0),
|
||||
'*-fN *' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -O exit'));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
|
||||
});
|
||||
|
||||
it('does not spawn a master when the per-server lock is already held', function () {
|
||||
config([
|
||||
'constants.ssh.mux_enabled' => true,
|
||||
'constants.ssh.mux_lock_timeout' => 0,
|
||||
]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake([
|
||||
'*-O check*' => Process::result(exitCode: 1),
|
||||
]);
|
||||
|
||||
$lockKey = 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
|
||||
$held = Cache::lock($lockKey, 30);
|
||||
expect($held->get())->toBeTrue();
|
||||
|
||||
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse();
|
||||
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
|
||||
|
||||
$held->release();
|
||||
});
|
||||
|
||||
it('returns false and runs no ssh when multiplexing is disabled', function () {
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
@@ -127,3 +140,182 @@ it('returns false and runs no process when multiplexing is globally disabled', f
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('adds mux options to ssh commands only after the explicit master is ready', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake([
|
||||
'*-O check*' => Process::result(exitCode: 1),
|
||||
'*-fN *' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
|
||||
|
||||
expect($command)
|
||||
->toContain('-o ControlMaster=auto')
|
||||
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
|
||||
->toContain('-o ControlPersist=3600')
|
||||
->toContain("'bash -se' << \\")
|
||||
->not->toContain('<< $delimiter');
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
|
||||
});
|
||||
|
||||
it('can generate terminal ssh commands without a hard command timeout', function () {
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
|
||||
|
||||
expect($command)
|
||||
->toStartWith('ssh ')
|
||||
->not->toStartWith('timeout ')
|
||||
->not->toContain('timeout 3600 ssh');
|
||||
});
|
||||
|
||||
it('omits multiplexing options and setup when disabled for a command', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake();
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true);
|
||||
|
||||
expect($command)
|
||||
->not->toContain('-o ControlMaster=auto')
|
||||
->not->toContain('-o ControlPath=')
|
||||
->not->toContain('-o ControlPersist=');
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('adds mux options to scp commands only after the explicit master is ready', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
Process::fake([
|
||||
'*-O check*' => Process::result(exitCode: 1),
|
||||
'*-fN *' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
$command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
|
||||
|
||||
expect($command)
|
||||
->toContain('-o ControlMaster=auto')
|
||||
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
|
||||
->toContain('-o ControlPersist=3600');
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
|
||||
});
|
||||
|
||||
it('kills only old orphaned ssh masters whose control socket no longer exists', function () {
|
||||
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
File::ensureDirectoryExists($muxDir);
|
||||
|
||||
$liveSocket = $muxDir.'/mux_live_'.uniqid();
|
||||
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
|
||||
$youngSocket = $muxDir.'/mux_young_'.uniqid();
|
||||
File::put($liveSocket, 'x');
|
||||
|
||||
Process::fake([
|
||||
'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4
|
||||
".
|
||||
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4
|
||||
".
|
||||
"333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4
|
||||
"),
|
||||
'kill*' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
$job = new CleanupStaleMultiplexedConnections;
|
||||
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333'));
|
||||
|
||||
File::delete($liveSocket);
|
||||
});
|
||||
|
||||
it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () {
|
||||
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
|
||||
|
||||
Process::fake([
|
||||
'ps*' => Process::result(output: '100 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4
|
||||
'.
|
||||
'200 100 5000 cloudflared access ssh --hostname host.example.com
|
||||
'.
|
||||
'300 2176 5000 cloudflared access ssh --hostname host.example.com
|
||||
'.
|
||||
'400 2176 30 cloudflared access ssh --hostname host.example.com
|
||||
'.
|
||||
'2176 1 9000 /usr/bin/some-supervisor
|
||||
'),
|
||||
'kill*' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
$job = new CleanupStaleMultiplexedConnections;
|
||||
$method = new ReflectionMethod($job, 'cleanupOrphanedCloudflaredProcesses');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '300'));
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '200'));
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '400'));
|
||||
});
|
||||
|
||||
it('dry-run mode logs orphans but kills nothing when reaping is disabled', function () {
|
||||
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
File::ensureDirectoryExists($muxDir);
|
||||
|
||||
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
|
||||
|
||||
Process::fake([
|
||||
'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4
|
||||
"),
|
||||
'kill*' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
$job = new CleanupStaleMultiplexedConnections;
|
||||
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill'));
|
||||
});
|
||||
|
||||
it('removes mux files for non-existent servers when reaping is enabled', function () {
|
||||
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
|
||||
Storage::fake('ssh-mux');
|
||||
$file = 'mux_ghost'.uniqid();
|
||||
Storage::disk('ssh-mux')->put($file, 'x');
|
||||
Process::fake();
|
||||
|
||||
$job = new CleanupStaleMultiplexedConnections;
|
||||
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps mux files for non-existent servers in dry-run mode', function () {
|
||||
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
|
||||
Storage::fake('ssh-mux');
|
||||
$file = 'mux_ghost'.uniqid();
|
||||
Storage::disk('ssh-mux')->put($file, 'x');
|
||||
Process::fake();
|
||||
|
||||
$job = new CleanupStaleMultiplexedConnections;
|
||||
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue();
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
@@ -46,3 +46,22 @@ it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function (
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
|
||||
&& $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
|
||||
});
|
||||
|
||||
it('syncs postgres upgrade script to BunnyCDN during full sync', function () {
|
||||
Http::fake([
|
||||
'https://cdn.coollabs.io/coolify/*' => Http::response('', 404),
|
||||
'https://storage.bunnycdn.com/*' => Http::response([], 201),
|
||||
'https://api.bunny.net/purge*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$this->artisan('sync:bunny')
|
||||
->expectsConfirmation('Are you sure you want to sync?', 'yes')
|
||||
->expectsOutputToContain('BunnyCDN sync: Complete')
|
||||
->assertExitCode(0);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'PUT'
|
||||
&& $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify/upgrade-postgres.sh');
|
||||
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
|
||||
&& $request['url'] === 'https://cdn.coollabs.io/coolify/upgrade-postgres.sh');
|
||||
});
|
||||
@@ -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=""');
|
||||
});
|
||||
});
|
||||
@@ -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' => [
|
||||
|
||||
@@ -447,6 +447,15 @@ it('container prune excludes persistent resource types', function () {
|
||||
expect($sourceFile)->toContain('label=coolify.managed=true');
|
||||
});
|
||||
|
||||
it('uses persisted buildx metadata when pruning the railpack builder', function () {
|
||||
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
|
||||
|
||||
expect($sourceFile)
|
||||
->toContain('docker run --rm -v \\$HOME/.docker/buildx:/root/.docker/buildx')
|
||||
->toContain('docker buildx prune --builder coolify-railpack -af')
|
||||
->not->toContain('--buildkitd-flags');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('persists buildx metadata between the helper container and host cleanup', function () {
|
||||
$sourceFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
|
||||
|
||||
expect($sourceFile)
|
||||
->toContain('mkdir -p {$this->serverUserHomeDir}/.docker/buildx')
|
||||
->toContain('-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx');
|
||||
|
||||
expect(substr_count($sourceFile, '{$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock'))->toBe(3);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Git operations authenticate with the SSH key assigned in the UI. Coolify writes that key to a
|
||||
* per-deployment path (/root/.ssh/id_rsa_coolify_<deployment_uuid>) instead of the shared
|
||||
* /root/.ssh/id_rsa, so it can neither overwrite the server root's own key nor race with other
|
||||
* concurrent operations on the same host. `-o IdentitiesOnly=yes` makes ssh offer only that key.
|
||||
* On the host path an EXIT trap removes the key when the shell finishes; the docker path runs in an
|
||||
* ephemeral container, so it needs no cleanup.
|
||||
*/
|
||||
$keyPath = '/root/.ssh/id_rsa_coolify_test-deployment-uuid';
|
||||
|
||||
it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT") // removed when the shell exits
|
||||
->not->toContain('tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key
|
||||
});
|
||||
|
||||
it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', true);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->not->toContain('trap ') // ephemeral container, no cleanup needed
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$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($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT")
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
// exec_in_docker = false → the loadComposeFile / host clone path
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT")
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$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($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->source = $gitlabSource;
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("tee {$keyPath}")
|
||||
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
|
||||
->toContain("trap 'rm -f {$keyPath}' EXIT")
|
||||
->not->toContain('tee /root/.ssh/id_rsa >');
|
||||
});
|
||||
|
||||
it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) {
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain("GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify")
|
||||
->not->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
|
||||
});
|
||||
|
||||
it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) {
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
|
||||
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('other');
|
||||
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
|
||||
|
||||
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify')
|
||||
->not->toContain($keyPath);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -156,7 +156,7 @@ describe('Git submodule credential propagation', function () {
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa_coolify_test-uuid -o IdentitiesOnly=yes';
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
|
||||
|
||||
@@ -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('<script>');
|
||||
expect($escaped)->not->toContain('<script>');
|
||||
expect($escapedLog)
|
||||
->toContain('<script>')
|
||||
->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('<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('<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('<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('<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('<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('<>&"'');
|
||||
});
|
||||
|
||||
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('<div');
|
||||
expect($escaped)->toContain('<img');
|
||||
expect($escaped)->toContain('<script>');
|
||||
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: <script>alert("XSS")</script>
|
||||
// 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('<script>');
|
||||
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('<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('<style>');
|
||||
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('<script>alert("XSS")</script>');
|
||||
|
||||
// Step 2: Stored in data-log-content attribute
|
||||
// <div data-log-content="<script>alert("XSS")</script>" 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('<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; (10 levels deep)
|
||||
// Each iteration decodes one level, causing excessive CPU usage
|
||||
|
||||
$normalEntity = '&lt;script>';
|
||||
// Normal case: 2-3 iterations to fully decode
|
||||
expect($normalEntity)->toContain('&');
|
||||
});
|
||||
|
||||
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;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): &lt;
|
||||
// Iteration 1: &<
|
||||
// Iteration 2: <
|
||||
// Total: 2 iterations (well within limit of 3)
|
||||
|
||||
$doubleEncoded = '&lt;script&gt;';
|
||||
expect($doubleEncoded)->toContain('&');
|
||||
|
||||
// Triple-encoding (rare but possible): &amp;lt;
|
||||
// Iteration 1: &<
|
||||
// Iteration 2: &<
|
||||
// Iteration 3: <
|
||||
// Total: 3 iterations (exactly at limit)
|
||||
|
||||
$tripleEncoded = '&amp;lt;div&amp;gt;';
|
||||
expect($tripleEncoded)->toContain('&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; (5 levels)
|
||||
// Iteration 1: &amp;amp;amp;
|
||||
// Iteration 2: &amp;amp;
|
||||
// Iteration 3: &amp;
|
||||
// Stop: Max iterations reached
|
||||
// Output: & (partially decoded, but safe from DoS)
|
||||
|
||||
$maliciousInput = str_repeat('&', 10).'lt;script>';
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -10,12 +10,12 @@ class SshRetryMechanismTest extends TestCase
|
||||
{
|
||||
public function test_ssh_retry_handler_exists()
|
||||
{
|
||||
$this->assertTrue(class_exists(\App\Helpers\SshRetryHandler::class));
|
||||
$this->assertTrue(class_exists(SshRetryHandler::class));
|
||||
}
|
||||
|
||||
public function test_ssh_retryable_trait_exists()
|
||||
{
|
||||
$this->assertTrue(trait_exists(\App\Traits\SshRetryable::class));
|
||||
$this->assertTrue(trait_exists(SshRetryable::class));
|
||||
}
|
||||
|
||||
public function test_retry_on_ssh_connection_errors()
|
||||
@@ -50,6 +50,24 @@ class SshRetryMechanismTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_generic_ssh_exit_255_error_is_retryable()
|
||||
{
|
||||
$handler = new class
|
||||
{
|
||||
use SshRetryable;
|
||||
|
||||
// Make methods public for testing
|
||||
public function test_is_retryable_ssh_error($error)
|
||||
{
|
||||
return $this->isRetryableSshError($error);
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertTrue(
|
||||
$handler->test_is_retryable_ssh_error('SSH command failed with exit code: 255')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_non_ssh_errors_are_not_retryable()
|
||||
{
|
||||
$handler = new class
|
||||
@@ -141,6 +159,32 @@ class SshRetryMechanismTest extends TestCase
|
||||
$this->assertEquals(3, $attemptCount);
|
||||
}
|
||||
|
||||
public function test_retry_succeeds_after_generic_ssh_exit_255_failure()
|
||||
{
|
||||
$attemptCount = 0;
|
||||
|
||||
config([
|
||||
'constants.ssh.max_retries' => 3,
|
||||
'constants.ssh.retry_base_delay' => 0,
|
||||
]);
|
||||
|
||||
$result = SshRetryHandler::retry(
|
||||
function () use (&$attemptCount) {
|
||||
$attemptCount++;
|
||||
if ($attemptCount === 1) {
|
||||
throw new \RuntimeException('SSH command failed with exit code: 255');
|
||||
}
|
||||
|
||||
return 'success';
|
||||
},
|
||||
['test' => 'generic_ssh_255_retry_test'],
|
||||
true
|
||||
);
|
||||
|
||||
$this->assertEquals('success', $result);
|
||||
$this->assertEquals(2, $attemptCount);
|
||||
}
|
||||
|
||||
public function test_retry_fails_after_max_attempts()
|
||||
{
|
||||
$attemptCount = 0;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
function assertBashSyntaxIsValid(string $path): void
|
||||
{
|
||||
$process = proc_open(
|
||||
['bash', '-n', getcwd().'/'.$path],
|
||||
[
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
],
|
||||
$pipes,
|
||||
getcwd()
|
||||
);
|
||||
|
||||
expect($process)->toBeResource();
|
||||
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
expect($exitCode, trim($stdout."\n".$stderr))->toBe(0);
|
||||
}
|
||||
|
||||
it('ships postgres upgrade scripts with valid bash syntax', function () {
|
||||
assertBashSyntaxIsValid('scripts/upgrade-postgres.sh');
|
||||
assertBashSyntaxIsValid('other/nightly/upgrade-postgres.sh');
|
||||
});
|
||||
|
||||
it('downloads postgres upgrade script during install and upgrade without auto-running it', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($script)
|
||||
->toContain('upgrade-postgres.sh')
|
||||
->toContain('curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh')
|
||||
->toContain('chmod +x')
|
||||
->not->toContain('bash /data/coolify/source/upgrade-postgres.sh');
|
||||
})->with([
|
||||
'stable install' => 'scripts/install.sh',
|
||||
'nightly install' => 'other/nightly/install.sh',
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($script)
|
||||
->toContain('docker-compose.postgres-upgrade.yml')
|
||||
->toContain('Including PostgreSQL upgrade compose override in image extraction')
|
||||
->toContain('Using PostgreSQL upgrade compose override');
|
||||
})->with([
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('uses postgres 18 compatible mount path in generated override and restore container', function () {
|
||||
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
|
||||
|
||||
expect($script)
|
||||
->toContain("printf '%s' '/var/lib/postgresql'")
|
||||
->toContain("printf '%s' '/var/lib/postgresql/data'")
|
||||
->toContain('- coolify-db:${mount_path}')
|
||||
->toContain('-v "${TARGET_VOLUME}:${TARGET_MOUNT_PATH}"');
|
||||
});
|
||||
|
||||
it('persists rollback metadata and exposes a rollback command', function () {
|
||||
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
|
||||
|
||||
expect($script)
|
||||
->toContain('ROLLBACK_FILE="${SOURCE_DIR}/postgres-upgrade-rollback.env"')
|
||||
->toContain('$0 rollback')
|
||||
->toContain('write_rollback_file')
|
||||
->toContain('PREVIOUS_VOLUME=')
|
||||
->toContain('PREVIOUS_IMAGE=')
|
||||
->toContain('PREVIOUS_MOUNT_PATH=')
|
||||
->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');
|
||||
});
|
||||
Reference in new issue
Block a user