mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 02:27:57 +00:00
feat(github): add GitHub App connection testing
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user