diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 351407dac..b03d0fa67 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -47,19 +47,19 @@ class Change extends Component public int $customPort; - public int $appId; + public ?int $appId = null; - public int $installationId; + public ?int $installationId = null; - public string $clientId; + public ?string $clientId = null; - public string $clientSecret; + public ?string $clientSecret = null; - public string $webhookSecret; + public ?string $webhookSecret = null; public bool $isSystemWide; - public int $privateKeyId; + public ?int $privateKeyId = null; public ?string $contents = null; @@ -78,16 +78,16 @@ class Change extends Component 'htmlUrl' => 'required|string', 'customUser' => 'required|string', 'customPort' => 'required|int', - 'appId' => 'required|int', - 'installationId' => 'required|int', - 'clientId' => 'required|string', - 'clientSecret' => 'required|string', - 'webhookSecret' => 'required|string', + 'appId' => 'nullable|int', + 'installationId' => 'nullable|int', + 'clientId' => 'nullable|string', + 'clientSecret' => 'nullable|string', + 'webhookSecret' => 'nullable|string', 'isSystemWide' => 'required|bool', 'contents' => 'nullable|string', 'metadata' => 'nullable|string', 'pullRequests' => 'nullable|string', - 'privateKeyId' => 'required|int', + 'privateKeyId' => 'nullable|int', ]; public function boot() @@ -148,47 +148,48 @@ public function checkPermissions() try { $this->authorize('view', $this->github_app); + // Validate required fields before attempting to fetch permissions + $missingFields = []; + + if (! $this->github_app->app_id) { + $missingFields[] = 'App ID'; + } + + if (! $this->github_app->private_key_id) { + $missingFields[] = 'Private Key'; + } + + if (! empty($missingFields)) { + $fieldsList = implode(', ', $missingFields); + $this->dispatch('error', "Cannot fetch permissions. Please set the following required fields first: {$fieldsList}"); + + return; + } + + // Verify the private key exists and is accessible + if (! $this->github_app->privateKey) { + $this->dispatch('error', 'Private Key not found. Please select a valid private key.'); + + return; + } + GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->dispatch('success', 'Github App permissions updated.'); } catch (\Throwable $e) { + // Provide better error message for unsupported key formats + $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 check() - // { - - // Need administration:read:write permission - // https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository - - // $github_access_token = generateGithubInstallationToken($this->github_app); - // $repositories = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/installation/repositories?per_page=100"); - // $runners_by_repository = collect([]); - // $repositories = $repositories->json()['repositories']; - // foreach ($repositories as $repository) { - // $runners_downloads = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/downloads"); - // $runners = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners"); - // $token = Http::withHeaders([ - // 'Authorization' => "Bearer $github_access_token", - // 'Accept' => 'application/vnd.github+json' - // ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/registration-token"); - // $token = $token->json(); - // $remove_token = Http::withHeaders([ - // 'Authorization' => "Bearer $github_access_token", - // 'Accept' => 'application/vnd.github+json' - // ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/remove-token"); - // $remove_token = $remove_token->json(); - // $runners_by_repository->put($repository['full_name'], [ - // 'token' => $token, - // 'remove_token' => $remove_token, - // 'runners' => $runners->json(), - // 'runners_downloads' => $runners_downloads->json() - // ]); - // } - - // } - public function mount() { try { @@ -343,7 +344,10 @@ public function createGithubAppManually() $this->github_app->app_id = '1234567890'; $this->github_app->installation_id = '1234567890'; $this->github_app->save(); - $this->dispatch('success', 'Github App updated.'); + + // Redirect to avoid Livewire morphing issues when view structure changes + return redirect()->route('source.github.show', ['github_app_uuid' => $this->github_app->uuid]) + ->with('success', 'Github App updated. You can now configure the details.'); } public function instantSave() diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index f5d851b64..2f1482c89 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -50,11 +50,9 @@ public function createGitHubApp() 'html_url' => $this->html_url, 'custom_user' => $this->custom_user, 'custom_port' => $this->custom_port, + 'is_system_wide' => $this->is_system_wide, 'team_id' => currentTeam()->id, ]; - if (isCloud()) { - $payload['is_system_wide'] = $this->is_system_wide; - } $github_app = GithubApp::create($payload); if (session('from')) { session(['from' => session('from') + ['source_id' => $github_app->id]]); diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 5550df81f..0d643306c 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -12,6 +12,7 @@ class GithubApp extends BaseModel protected $casts = [ 'is_public' => 'boolean', + 'is_system_wide' => 'boolean', 'type' => 'string', ]; diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 7e6256259..6e1da2a02 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -1,7 +1,7 @@
@if (data_get($github_app, 'app_id'))
-
+

GitHub App

@if (data_get($github_app, 'installation_id')) @@ -40,8 +40,8 @@ @else
-
-
+
+
Sync Name @@ -73,23 +73,23 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans instantSave id="isSystemWide" />
@endif -
+
-
+
-
+
-
+
+

Permissions

@can('view', $github_app) Refetch @@ -120,7 +120,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans @endcan
-
+
@endif @else -
+

GitHub App

@can('delete', $github_app) @@ -228,7 +228,7 @@ class=""
@can('create', $github_app) @if (!isCloud() || isDev()) -
+
@if ($ipv4) @@ -250,7 +250,7 @@ class=""
@else -
+

Register a GitHub App

@@ -261,11 +261,11 @@ class="" @endif
- - - {{-- --}}
@else diff --git a/tests/Feature/GithubSourceChangeTest.php b/tests/Feature/GithubSourceChangeTest.php new file mode 100644 index 000000000..06c04dd41 --- /dev/null +++ b/tests/Feature/GithubSourceChangeTest.php @@ -0,0 +1,208 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + // Set current team + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('GitHub Source Change Component', function () { + test('can mount with newly created github app with null app_id', function () { + // Create a GitHub app without app_id (simulating a newly created source) + $githubApp = GithubApp::create([ + 'name' => 'Test 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, + // app_id is intentionally not set (null in database) + ]); + + // Test that the component can mount without errors + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->assertSet('appId', null) + ->assertSet('installationId', null) + ->assertSet('clientId', null) + ->assertSet('clientSecret', null) + ->assertSet('webhookSecret', null) + ->assertSet('privateKeyId', null); + }); + + test('can mount with fully configured github app', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => 'test-private-key-content', + 'team_id' => $this->team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Test 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, + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'webhook_secret' => 'test-webhook-secret', + '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('appId', 12345) + ->assertSet('installationId', 67890) + ->assertSet('clientId', 'test-client-id') + ->assertSet('clientSecret', 'test-client-secret') + ->assertSet('webhookSecret', 'test-webhook-secret') + ->assertSet('privateKeyId', $privateKey->id); + }); + + test('can update github app from null to valid values', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => 'test-private-key-content', + 'team_id' => $this->team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Test 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, + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->set('appId', 12345) + ->set('installationId', 67890) + ->set('clientId', 'new-client-id') + ->set('clientSecret', 'new-client-secret') + ->set('webhookSecret', 'new-webhook-secret') + ->set('privateKeyId', $privateKey->id) + ->call('submit') + ->assertDispatched('success'); + + // Verify the database was updated + $githubApp->refresh(); + expect($githubApp->app_id)->toBe(12345); + expect($githubApp->installation_id)->toBe(67890); + expect($githubApp->client_id)->toBe('new-client-id'); + expect($githubApp->private_key_id)->toBe($privateKey->id); + }); + + test('validation allows nullable values for app configuration', function () { + $githubApp = GithubApp::create([ + 'name' => 'Test 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, + ]); + + // Test that validation passes with null values + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->call('submit') + ->assertHasNoErrors(); + }); + + test('createGithubAppManually redirects to avoid morphing issues', function () { + $githubApp = GithubApp::create([ + 'name' => 'Test 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, + ]); + + // Test that createGithubAppManually redirects instead of updating in place + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->call('createGithubAppManually') + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])); + + // Verify the database was updated + $githubApp->refresh(); + expect($githubApp->app_id)->toBe('1234567890'); + expect($githubApp->installation_id)->toBe('1234567890'); + }); + + test('checkPermissions validates required fields', function () { + // Create a GitHub app without app_id and private_key_id + $githubApp = GithubApp::create([ + 'name' => 'Test 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, + ]); + + // Test that checkPermissions fails with appropriate error + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->call('checkPermissions') + ->assertDispatched('error', function ($event, $message) { + return str_contains($message, 'App ID') && str_contains($message, 'Private Key'); + }); + }); + + test('checkPermissions validates private key exists', function () { + $githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'private_key_id' => 99999, // Non-existent private key ID + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + // Test that checkPermissions fails when private key doesn't exist + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->call('checkPermissions') + ->assertDispatched('error', function ($event, $message) { + return str_contains($message, 'Private Key not found'); + }); + }); +}); diff --git a/tests/Feature/GithubSourceCreateTest.php b/tests/Feature/GithubSourceCreateTest.php new file mode 100644 index 000000000..82343092c --- /dev/null +++ b/tests/Feature/GithubSourceCreateTest.php @@ -0,0 +1,108 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + // Set current team + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('GitHub Source Create Component', function () { + test('creates github app with default values', function () { + Livewire::test(Create::class) + ->assertSuccessful() + ->set('name', 'my-test-app') + ->call('createGitHubApp') + ->assertRedirect(); + + $githubApp = GithubApp::where('name', 'my-test-app')->first(); + + expect($githubApp)->not->toBeNull(); + expect($githubApp->name)->toBe('my-test-app'); + expect($githubApp->api_url)->toBe('https://api.github.com'); + expect($githubApp->html_url)->toBe('https://github.com'); + expect($githubApp->custom_user)->toBe('git'); + expect($githubApp->custom_port)->toBe(22); + expect($githubApp->is_system_wide)->toBeFalse(); + expect($githubApp->team_id)->toBe($this->team->id); + }); + + test('creates github app with system wide enabled', function () { + Livewire::test(Create::class) + ->assertSuccessful() + ->set('name', 'system-wide-app') + ->set('is_system_wide', true) + ->call('createGitHubApp') + ->assertRedirect(); + + $githubApp = GithubApp::where('name', 'system-wide-app')->first(); + + expect($githubApp)->not->toBeNull(); + expect($githubApp->is_system_wide)->toBeTrue(); + }); + + test('creates github app with custom organization', function () { + Livewire::test(Create::class) + ->assertSuccessful() + ->set('name', 'org-app') + ->set('organization', 'my-org') + ->call('createGitHubApp') + ->assertRedirect(); + + $githubApp = GithubApp::where('name', 'org-app')->first(); + + expect($githubApp)->not->toBeNull(); + expect($githubApp->organization)->toBe('my-org'); + }); + + test('creates github app with custom git settings', function () { + Livewire::test(Create::class) + ->assertSuccessful() + ->set('name', 'enterprise-app') + ->set('api_url', 'https://github.enterprise.com/api/v3') + ->set('html_url', 'https://github.enterprise.com') + ->set('custom_user', 'git-custom') + ->set('custom_port', 2222) + ->call('createGitHubApp') + ->assertRedirect(); + + $githubApp = GithubApp::where('name', 'enterprise-app')->first(); + + expect($githubApp)->not->toBeNull(); + expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3'); + expect($githubApp->html_url)->toBe('https://github.enterprise.com'); + expect($githubApp->custom_user)->toBe('git-custom'); + expect($githubApp->custom_port)->toBe(2222); + }); + + test('validates required fields', function () { + Livewire::test(Create::class) + ->assertSuccessful() + ->set('name', '') + ->call('createGitHubApp') + ->assertHasErrors(['name']); + }); + + test('redirects to github app show page after creation', function () { + $component = Livewire::test(Create::class) + ->set('name', 'redirect-test') + ->call('createGitHubApp'); + + $githubApp = GithubApp::where('name', 'redirect-test')->first(); + + $component->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])); + }); +});