mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Stop calling /zen before each installation token request. A short network problem to GitHub failed deployments with a cURL timeout on /zen. assertGithubClockInSync now takes the failed response and reads its Date header, so the "System time is out of sync" error still shows when GitHub rejects the JWT. Call it on failed JWT requests in the webhook installation check, the permission job, the source connection test, and the server transfer checklist. Remove the /zen fakes from the tests.
313 lines
16 KiB
PHP
313 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/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/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();
|
|
});
|