Files
coolify/tests/Unit/ServerTransfer/TransferFinishChecklistTest.php
T

279 lines
14 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'])
->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 2 done.')
->assertSee('Server')
->assertSee('Restart Sentinel')
->call('markStepDone', 'sentinel:server')
->assertSee('1 of 2 done.')
->assertSee('1 step is OK or done')
->assertSee('Finish with open steps')
->call('markStepDone', 'server_connection: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 2 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 2 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');
});