From aeeb2665cd950dbb74e78701b973217dd5a4ca58 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:54:15 +0200 Subject: [PATCH] feat(github): add GitHub App connection testing --- app/Livewire/Source/Github/Change.php | 56 +++++++ app/Models/GithubApp.php | 13 ++ .../livewire/source/github/change.blade.php | 9 +- resources/views/source/all.blade.php | 8 +- .../Application/GithubSourceChangeTest.php | 157 ++++++++++++++++++ 5 files changed, 237 insertions(+), 6 deletions(-) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index a24ed9ce3..1876b8c49 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -8,6 +8,7 @@ use App\Models\PrivateKey; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Livewire\Component; @@ -79,6 +80,8 @@ class Change extends Component public string $activeTab = 'general'; + public bool $isConnected = false; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; protected function rules(): array @@ -230,6 +233,7 @@ class Change extends Component GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->syncData(false); + $this->isConnected = $this->github_app->isConnected(); $this->name = str($this->github_app->name)->kebab(); $this->dispatch('success', 'Github App permissions updated.'); @@ -247,6 +251,55 @@ class Change extends Component } } + public function testConnection() + { + try { + $this->authorize('view', $this->github_app); + + if (! $this->github_app->isConnected()) { + $this->dispatch('error', 'GitHub App is not fully set up. Please complete installation first.'); + + return; + } + + if (! $this->github_app->private_key_id || ! $this->github_app->privateKey) { + $this->dispatch('error', 'Private Key not found. Please select a valid private key.'); + + return; + } + + $jwt = generateGithubJwt($this->github_app); + $appResponse = Http::withHeaders([ + 'Authorization' => "Bearer $jwt", + 'Accept' => 'application/vnd.github+json', + ])->timeout(10)->get("{$this->github_app->api_url}/app"); + + if (! $appResponse->successful()) { + $error = data_get($appResponse->json(), 'message', 'Unknown error'); + $this->dispatch('error', "Connection failed: {$error}"); + + return; + } + + // Confirm installation credentials can mint an installation access token. + generateGithubInstallationToken($this->github_app); + + $appName = data_get($appResponse->json(), 'name') + ?? data_get($appResponse->json(), 'slug', 'unknown'); + $this->dispatch('success', "Connection successful! Authenticated as GitHub App: {$appName}"); + } catch (\Throwable $e) { + $errorMessage = $e->getMessage(); + if (str_contains($errorMessage, 'DECODER routines::unsupported') || + str_contains($errorMessage, 'parse your key')) { + $this->dispatch('error', 'The selected private key format is not supported for GitHub Apps.

Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY).

OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.'); + + return; + } + + return handleError($e, $this); + } + } + public function mount() { try { @@ -260,6 +313,7 @@ class Change extends Component // Sync data from model to properties $this->syncData(false); + $this->isConnected = $this->github_app->isConnected(); // Override name with kebab case for display $this->name = str($this->github_app->name)->kebab(); @@ -373,6 +427,7 @@ class Change extends Component $this->syncData(true); $this->github_app->save(); + $this->isConnected = $this->github_app->isConnected(); $this->dispatch('success', 'Github App updated.'); } catch (ValidationException $e) { throw $e; @@ -404,6 +459,7 @@ class Change extends Component $this->syncData(true); $this->github_app->save(); + $this->isConnected = $this->github_app->isConnected(); $this->dispatch('success', 'Github App updated.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index e5032d2d0..7c2f8c062 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -98,4 +98,17 @@ class GithubApp extends BaseModel }, ); } + + /** + * A private GitHub App is connected once it has been registered and installed. + * Public sources do not require installation credentials. + */ + public function isConnected(): bool + { + if ($this->is_public) { + return true; + } + + return filled($this->app_id) && filled($this->installation_id); + } } diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 051f9809f..3b0ac9388 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -2,11 +2,18 @@ @if (data_get($github_app, 'app_id'))
-

GitHub App

+
+

GitHub App

+ @if ($isConnected) + + @endif +
@if (data_get($github_app, 'installation_id')) Save + Test Connection @endif @can('delete', $github_app) @if ($applications->count() > 0) diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index 904005248..c74e46093 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -25,12 +25,10 @@ {{ $source->name }}
- @if (is_null($source->app_id)) - Setup required + @if ($source->isConnected()) + Connected @else - @if ($source->organization) - Organization: {{ $source->organization }} - @endif + Setup required @endif
diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index 40a4e7314..6eb97e420 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -653,4 +653,161 @@ describe('GitHub Source Change Component', function () { Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app'); }); + + test('isConnected is true only when app and installation are present', function () { + $incomplete = GithubApp::create([ + 'name' => 'Incomplete App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $connected = GithubApp::create([ + 'name' => 'Connected App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $public = new GithubApp([ + 'is_public' => true, + ]); + + expect($incomplete->isConnected())->toBeFalse() + ->and($connected->isConnected())->toBeTrue() + ->and($public->isConnected())->toBeTrue(); + }); + + test('shows connected badge and test connection for installed github apps', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Connected 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, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->assertSet('isConnected', true) + ->assertSee('Connected') + ->assertSee('Test Connection'); + }); + + test('testConnection succeeds when github app credentials are valid', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Connected 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, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + 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([ + 'name' => 'Coolify GitHub App', + 'slug' => 'coolify-github-app', + ]), + 'https://api.github.com/app/installations/67890/access_tokens' => Http::response([ + 'token' => 'ghs_test_installation_token', + ]), + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->call('testConnection') + ->assertDispatched('success', 'Connection successful! Authenticated as GitHub App: Coolify GitHub App'); + }); + + test('testConnection fails when github app is not fully installed', function () { + $githubApp = GithubApp::create([ + 'name' => 'Incomplete GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->assertSet('isConnected', false) + ->call('testConnection') + ->assertDispatched('error', 'GitHub App is not fully set up. Please complete installation first.'); + }); + + test('sources list shows Connected for finished github apps', function () { + GithubApp::create([ + 'name' => 'Finished 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, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + GithubApp::create([ + 'name' => 'Incomplete 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, + 'is_public' => false, + ]); + + $this->get(route('source.all')) + ->assertSuccessful() + ->assertSee('Finished GitHub App') + ->assertSee('Connected') + ->assertSee('Incomplete GitHub App') + ->assertSee('Setup required'); + }); });