feat(github): add GitHub App connection testing

This commit is contained in:
Andras Bacsai
2026-07-21 21:54:15 +02:00
parent b2fed043c5
commit aeeb2665cd
5 changed files with 237 additions and 6 deletions
+56
View File
@@ -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. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>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);
+13
View File
@@ -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);
}
}
@@ -2,11 +2,18 @@
@if (data_get($github_app, 'app_id'))
<form wire:submit='submit'>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
<div class="flex items-center gap-2">
<h1>GitHub App</h1>
@if ($isConnected)
<x-status-badge status="Connected" type="success" />
@endif
</div>
<div class="flex gap-2">
@if (data_get($github_app, 'installation_id'))
<x-forms.button canGate="update" :canResource="$github_app" type="submit"
:disabled="$activeTab !== 'general'">Save</x-forms.button>
<x-forms.button canGate="view" :canResource="$github_app"
wire:click.prevent="testConnection">Test Connection</x-forms.button>
@endif
@can('delete', $github_app)
@if ($applications->count() > 0)
+3 -5
View File
@@ -25,12 +25,10 @@
<x-git-icon class="inline-block w-4 h-4 mr-1" git="App\Models\GithubApp" />
{{ $source->name }}
</div>
@if (is_null($source->app_id))
<span class="box-description text-warning">Setup required</span>
@if ($source->isConnected())
<span class="box-description text-success">Connected</span>
@else
@if ($source->organization)
<span class="box-description">Organization: {{ $source->organization }}</span>
@endif
<span class="box-description text-warning">Setup required</span>
@endif
</div>
</a>
@@ -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');
});
});