mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 00:33:44 -04:00
Import: - Check every uuid in a bundle against a strict id format before any write, and quote the SSH mux socket and key paths in local shell commands. A bundle uuid could run commands in the Coolify container. - Validate bundle fqdns, git source URLs, paths, networks, cron expressions, and database ports; cap the number of items. - Do not confirm that another team has a uuid. - Refuse a project icon path with a uuid that is not a plain id. Claim, finish, and migrate: - Pass checklist step keys to wire:click with Js::from (stored XSS). - Link only to https target server URLs. - Validate target_instance_url in /transfer/complete; never prefill an unverified URL for a transfer back; scope the transfer-back token to write+deploy for 1 hour. - Release on an owner marker only when it is newer than the own claim. - Do not touch apps that only use the server as an additional destination when an archive is released or deleted. - Give Sentinel a new token on claim. - Add a checklist step to remove the previous instance's SSH key. - Block a transfer while backups or scheduled tasks run. - Authorize refreshMigration and PrivateKey checkConnection, clear tokens from Livewire state, hide migration log/error from members. Archives: - Refuse ip, user, port, private key, and Cloudflare tunnel changes. - Throttle Enable management and require a free server slot for it and for a transfer back. - Skip cloud provider calls. - Re-read force_disabled before each SSH command, so running jobs stop. - Probe the import check target at the resolved address only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
16 KiB
PHP
315 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Events\TransferFinishChanged;
|
|
use App\Jobs\CheckTransferFinishStepsJob;
|
|
use App\Livewire\Server\TransferFinish;
|
|
use App\Models\Application;
|
|
use App\Models\DnsProviderZone;
|
|
use App\Models\GithubApp;
|
|
use App\Models\GithubRunnerConfig;
|
|
use App\Models\GitlabApp;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\ManagedDnsRecord;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\ServerTransfer\TransferFinishChecklist;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Server::flushIdentityMap();
|
|
Process::fake();
|
|
Storage::fake('ssh-keys');
|
|
InstanceSettings::forceCreate(['id' => 0, 'fqdn' => 'https://coolify-b.test']);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
$this->actingAs($this->user);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]);
|
|
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
|
|
|
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
|
openssl_pkey_export($rsaKey, $pem);
|
|
$this->githubApp = GithubApp::create([
|
|
'name' => 'deploy-bot', 'organization' => 'acme', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com',
|
|
'app_id' => 1234, 'installation_id' => 5678, 'webhook_secret' => 'gh-secret', 'is_system_wide' => false, 'is_public' => false,
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::create(['name' => 'gh', 'private_key' => $pem, 'team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->checklist = app(TransferFinishChecklist::class);
|
|
});
|
|
|
|
function finishTestApplication(object $test, array $attributes = []): Application
|
|
{
|
|
return Application::factory()->create([
|
|
'destination_id' => $test->destination->id,
|
|
'destination_type' => $test->destination->getMorphClass(),
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
function finishTestItem(Server $server, string $key): array
|
|
{
|
|
return collect(data_get($server->fresh()->server_metadata, 'transfer.finish.items'))->firstWhere('key', $key);
|
|
}
|
|
|
|
test('start lists a step for every kind of setting the server uses', function () {
|
|
$githubApplication = finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id, 'fqdn' => 'https://shop.example.test']);
|
|
$gitlabApp = GitlabApp::create(['name' => 'gitlab', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', 'team_id' => $this->team->id, 'is_system_wide' => false]);
|
|
finishTestApplication($this, ['source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id]);
|
|
$publicApplication = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'git_repository' => 'https://github.com/acme/public']);
|
|
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
|
|
$record = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => DnsProviderZone::factory()->create(['integration_token_id' => $token->id])->id]);
|
|
$record->addReference($githubApplication);
|
|
$runnerConfig = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
|
|
$keys = array_column($this->checklist->start($this->server), 'key');
|
|
|
|
expect($keys)->toContain(
|
|
'server_connection:server',
|
|
'sentinel:server',
|
|
"github_app_webhook:{$this->githubApp->uuid}",
|
|
"github_app_urls:{$this->githubApp->uuid}",
|
|
"gitlab_webhook:{$gitlabApp->uuid}",
|
|
"manual_webhook:{$publicApplication->uuid}",
|
|
"dns_record:{$record->uuid}",
|
|
'domain:shop.example.test',
|
|
"github_runners:{$runnerConfig->uuid}",
|
|
)->and($keys)->not->toContain("manual_webhook:{$githubApplication->uuid}");
|
|
});
|
|
|
|
test('the GitHub App webhook step checks the URL at GitHub and sets it with one click', function () {
|
|
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
|
|
$this->checklist->start($this->server);
|
|
$key = "github_app_webhook:{$this->githubApp->uuid}";
|
|
$hookUrl = 'https://coolify-a.test/webhooks/source/github/events';
|
|
Http::fake([
|
|
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
|
'https://api.github.com/app/hook/config' => function ($request) use (&$hookUrl) {
|
|
if ($request->method() === 'PATCH') {
|
|
$hookUrl = $request['url'];
|
|
}
|
|
|
|
return Http::response(['url' => $hookUrl, 'content_type' => 'json']);
|
|
},
|
|
]);
|
|
|
|
expect($this->checklist->check($this->server, $key)['status'])->toBe('action');
|
|
|
|
$item = $this->checklist->fix($this->server, $key);
|
|
|
|
expect($item['status'])->toBe('ok')
|
|
->and(finishTestItem($this->server, $key)['status'])->toBe('ok');
|
|
Http::assertSent(fn ($request) => $request->method() === 'PATCH' && $request['url'] === 'https://coolify-b.test/webhooks/source/github/events');
|
|
});
|
|
|
|
test('a webhook step turns OK when this instance receives a webhook after the transfer', function () {
|
|
$application = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'git_repository' => 'https://github.com/acme/public']);
|
|
$this->checklist->start($this->server);
|
|
$key = "manual_webhook:{$application->uuid}";
|
|
|
|
expect($this->checklist->check($this->server, $key)['status'])->toBe('action');
|
|
|
|
$this->travel(1)->minutes();
|
|
TransferFinishChecklist::recordWebhook('application', $application->uuid);
|
|
|
|
expect($this->checklist->check($this->server, $key)['status'])->toBe('ok');
|
|
});
|
|
|
|
test('a verified GitHub App webhook is recorded for the finish list', function () {
|
|
$body = json_encode(['action' => 'created', 'installation' => ['id' => 5678]]);
|
|
$this->call('POST', '/webhooks/source/github/events', [], [], [], [
|
|
'HTTP_X-GitHub-Event' => 'installation',
|
|
'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234',
|
|
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'gh-secret'),
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], $body);
|
|
|
|
expect(Cache::get("transfer-finish:webhook:github_app:{$this->githubApp->uuid}"))->not->toBeNull();
|
|
});
|
|
|
|
test('I finished closes the list and keeps the open steps as skipped', function () {
|
|
$this->checklist->start($this->server);
|
|
$this->checklist->markDone($this->server, 'server_connection:server');
|
|
|
|
$this->checklist->finish($this->server->fresh(), $this->user);
|
|
|
|
$finish = $this->checklist->of($this->server->fresh());
|
|
expect($finish['finished_by'])->toBe($this->user->id)
|
|
->and($finish['skipped'])->toBe(['sentinel:server', 'previous_ssh_key:server'])
|
|
->and($this->checklist->isOpen($this->server->fresh()))->toBeFalse();
|
|
});
|
|
|
|
test('the panel shows the open steps by group, collapses done steps, and finishes', function () {
|
|
$this->checklist->start($this->server);
|
|
|
|
Livewire::test(TransferFinish::class, ['server' => $this->server])
|
|
->assertSee('Finish the transfer')
|
|
->assertSee('0 of 3 done.')
|
|
->assertSee('Server')
|
|
->assertSee('Restart Sentinel')
|
|
->call('markStepDone', 'sentinel:server')
|
|
->assertSee('1 of 3 done.')
|
|
->assertSee('1 step is OK or done')
|
|
->assertSee('Finish with open steps')
|
|
->call('markStepDone', 'server_connection:server')
|
|
->call('markStepDone', 'previous_ssh_key:server')
|
|
->assertDontSee('Finish with open steps')
|
|
->call('finishTransfer')
|
|
->assertDontSee('Finish the transfer');
|
|
|
|
expect(finishTestItem($this->server, 'sentinel:server')['status'])->toBe('done');
|
|
});
|
|
|
|
test('the panel starts background checks when they are stale, and not again within 30 seconds', function () {
|
|
Queue::fake();
|
|
$this->checklist->start($this->server);
|
|
$component = Livewire::test(TransferFinish::class, ['server' => $this->server]);
|
|
|
|
$component->call('refreshSteps');
|
|
Queue::assertPushed(CheckTransferFinishStepsJob::class, 1);
|
|
|
|
$this->checklist->checkAll($this->server->fresh());
|
|
$component->call('refreshSteps');
|
|
Queue::assertPushed(CheckTransferFinishStepsJob::class, 1);
|
|
});
|
|
|
|
test('a failed check shows a short message and keeps the technical reason as details', function () {
|
|
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
|
|
$this->checklist->start($this->server);
|
|
Http::fake([
|
|
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
|
'https://api.github.com/app/hook/config' => Http::response(['message' => 'Bad credentials'], 401),
|
|
]);
|
|
|
|
$item = $this->checklist->check($this->server, "github_app_webhook:{$this->githubApp->uuid}");
|
|
|
|
expect($item['message'])->toBe('GitHub cannot be asked with the App key. No webhook arrived since the transfer.')
|
|
->and($item['error'])->toContain('Bad credentials')
|
|
->and($item['hint'])->toBe('GitHub must send webhooks to this instance.')
|
|
->and($item['details'][0]['value'])->toBe('https://coolify-b.test/webhooks/source/github/events');
|
|
});
|
|
|
|
test('team members cannot check or finish the transfer', function () {
|
|
$this->checklist->start($this->server);
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
$this->actingAs($member);
|
|
|
|
Livewire::test(TransferFinish::class, ['server' => $this->server])->call('finishTransfer')->assertForbidden();
|
|
Livewire::test(TransferFinish::class, ['server' => $this->server])->call('markStepDone', 'sentinel:server')->assertForbidden();
|
|
Livewire::test(TransferFinish::class, ['server' => $this->server])->call('fixStep', 'sentinel:server')->assertForbidden();
|
|
|
|
expect($this->checklist->isOpen($this->server->fresh()))->toBeTrue();
|
|
});
|
|
|
|
test('a Docker image application has no manual webhook step', function () {
|
|
$application = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'build_pack' => 'dockerimage', 'git_repository' => 'coollabsio/coolify']);
|
|
|
|
expect(array_column($this->checklist->start($this->server), 'key'))->not->toContain("manual_webhook:{$application->uuid}");
|
|
});
|
|
|
|
test('an application that lost its system-wide git source gets a git source step with a link', function () {
|
|
$environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
|
|
$application = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'environment_id' => $environment->id]);
|
|
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'claimed', 'git_source_applications' => [$application->uuid]]]])->save();
|
|
$key = "git_source:{$application->uuid}";
|
|
|
|
$items = collect($this->checklist->start($this->server));
|
|
$item = $items->firstWhere('key', $key);
|
|
expect($items->pluck('key'))->not->toContain("manual_webhook:{$application->uuid}")
|
|
->and($item['link'])->toStartWith('/project/')->toEndWith("/application/{$application->uuid}/source")
|
|
->and($this->checklist->check($this->server, $key)['status'])->toBe('action');
|
|
|
|
$gitlabApp = GitlabApp::create(['name' => 'gitlab', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', 'team_id' => $this->team->id, 'is_system_wide' => false]);
|
|
$application->forceFill(['source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id])->save();
|
|
|
|
expect($this->checklist->check($this->server, $key)['status'])->toBe('ok');
|
|
});
|
|
|
|
test('restarting Sentinel waits for its data instead of failing the step at once', function () {
|
|
$this->checklist->start($this->server);
|
|
|
|
Livewire::test(TransferFinish::class, ['server' => $this->server])
|
|
->call('fixStep', 'sentinel:server')
|
|
->assertDispatched('info')
|
|
->assertNotDispatched('error');
|
|
|
|
expect(finishTestItem($this->server, 'sentinel:server')['status'])->toBe('pending');
|
|
});
|
|
|
|
test('a changed step is broadcast to the team, and the open panel shows it without a reload', function () {
|
|
Event::fake([TransferFinishChanged::class]);
|
|
$this->checklist->start($this->server);
|
|
$component = Livewire::test(TransferFinish::class, ['server' => $this->server])->assertSee('0 of 3 done.');
|
|
|
|
$this->checklist->markDone($this->server->fresh(), 'sentinel:server');
|
|
|
|
Event::assertDispatched(TransferFinishChanged::class, fn ($event) => $event->serverUuid === $this->server->uuid && $event->teamId === $this->team->id);
|
|
$component->call('handleTransferFinishChanged', ['serverUuid' => $this->server->uuid])->assertSee('1 of 3 done.');
|
|
});
|
|
|
|
test('the GitHub App steps link to the App settings at GitHub', function () {
|
|
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
|
|
$items = collect($this->checklist->start($this->server));
|
|
|
|
expect($this->checklist->linkOf($items->firstWhere('key', "github_app_webhook:{$this->githubApp->uuid}")))->toBe(['https://github.com/organizations/acme/settings/apps/deploy-bot', 'Open on GitHub'])
|
|
->and($this->checklist->linkOf($items->firstWhere('key', "github_app_urls:{$this->githubApp->uuid}")))->toBe(['https://github.com/organizations/acme/settings/apps/deploy-bot', 'Open on GitHub']);
|
|
|
|
Livewire::test(TransferFinish::class, ['server' => $this->server])
|
|
->assertSeeHtml('href="https://github.com/organizations/acme/settings/apps/deploy-bot" target="_blank"')
|
|
->assertSee('Open on GitHub');
|
|
});
|
|
|
|
test('a step key is passed to Livewire as a JavaScript string, so a quote in a domain cannot run script', function () {
|
|
// parse_url keeps quotes in a host, so a domain step can have one in its key.
|
|
$host = (string) parse_url("https://x'-alert(1)-'.example.test", PHP_URL_HOST);
|
|
$items = $this->checklist->start($this->server);
|
|
$metadata = $this->server->fresh()->server_metadata;
|
|
$metadata['transfer']['finish']['items'][] = [...$items[0], 'key' => "domain:{$host}", 'type' => 'domain', 'subject' => $host, 'label' => "Domain {$host}"];
|
|
$this->server->forceFill(['server_metadata' => $metadata])->save();
|
|
|
|
$html = Livewire::test(TransferFinish::class, ['server' => $this->server->fresh()])->html();
|
|
|
|
expect($html)->toContain("checkStep('domain:x\\u0027-alert(1)-\\u0027.example.test')")
|
|
->and($html)->not->toContain("Step('domain:x'");
|
|
});
|
|
|
|
test('the SSH key of the previous instance is a manual step', function () {
|
|
$item = collect($this->checklist->start($this->server))->firstWhere('key', 'previous_ssh_key:server');
|
|
|
|
expect($item)->not->toBeNull()
|
|
->and($this->checklist->canCheck($item))->toBeFalse()
|
|
->and($this->checklist->linkOf($item))->toBe(["/server/{$this->server->uuid}/private-key", 'Open private key']);
|
|
|
|
$this->checklist->markDone($this->server, 'previous_ssh_key:server');
|
|
expect(finishTestItem($this->server, 'previous_ssh_key:server')['status'])->toBe('done');
|
|
});
|
|
|
|
test('a GitHub App link that is not a web URL, or a saved link to another host, is not shown', function () {
|
|
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
|
|
$items = collect($this->checklist->start($this->server));
|
|
$this->githubApp->forceFill(['html_url' => 'javascript:alert(1)//'])->save();
|
|
|
|
expect($this->checklist->linkOf($items->firstWhere('key', "github_app_webhook:{$this->githubApp->uuid}")))->toBeNull()
|
|
->and($this->checklist->linkOf(['type' => 'git_source', 'link' => '//evil.example/x']))->toBeNull()
|
|
->and($this->checklist->linkOf(['type' => 'git_source', 'link' => 'javascript:alert(1)']))->toBeNull();
|
|
});
|