Improve GitHub App setup flow

This commit is contained in:
Andras Bacsai committed 2026-06-03 09:33:46 +02:00
1 parent 6c3ea7f1db
commit 858b1906ec
6 files changed
+276 -28

No files matched your search

+99
View File
@@ -7,6 +7,8 @@ use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -84,6 +86,44 @@ describe('GitHub Source Change Component', function () {
->assertSet('privateKeyId', null);
});
test('creates one-time states for manifest conversion and installation callbacks', 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,
]);
$component = Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful();
$manifestState = $component->get('manifestState');
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($manifestState)->not->toBeEmpty()
->and($installState)->not->toBeEmpty()
->and($installState)->not->toBe($manifestState)
->and($installationUrl)->not->toContain($githubApp->uuid)
->and(Cache::get('github-app-setup-state:'.hash('sha256', $manifestState)))
->toMatchArray([
'action' => 'manifest',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
])
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
]);
});
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
config(['app.url' => 'http://localhost:8000']);
@@ -305,4 +345,63 @@ describe('GitHub Source Change Component', function () {
return str_contains($message, 'Private Key not found');
});
});
test('checkPermissions syncs refetched permissions into input fields', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'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,
'contents' => null,
'metadata' => null,
'pull_requests' => null,
]);
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([
'permissions' => [
'contents' => 'read',
'metadata' => 'read',
'pull_requests' => 'write',
],
]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('contents', null)
->assertSet('metadata', null)
->assertSet('pullRequests', null)
->call('checkPermissions')
->assertDispatched('success')
->assertSet('contents', 'read')
->assertSet('metadata', 'read')
->assertSet('pullRequests', 'write');
$githubApp->refresh();
expect($githubApp->contents)->toBe('read')
->and($githubApp->metadata)->toBe('read')
->and($githubApp->pull_requests)->toBe('write');
});
});
@@ -199,8 +199,9 @@ it('rejects replayed github app manifest states', function () {
it('requires authentication before processing github app install callbacks', function () {
Http::preventStrayRequests();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect();
Http::assertNothingSent();
@@ -209,22 +210,110 @@ it('requires authentication before processing github app install callbacks', fun
expect($this->githubApp->installation_id)->toBeNull();
});
it('rejects github app install callbacks for an unknown github app', function () {
it('rejects github app install callbacks with an app uuid as state', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertNotFound();
Http::assertNothingSent();
});
it('redirects browser github app install callbacks with missing or expired state to sources', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=install&installation_id=123456')
->assertRedirect(route('source.all'));
$this->get('/webhooks/source/github/install?state=expired-state&setup_action=install&installation_id=123456')
->assertRedirect(route('source.all'));
Http::assertNothingSent();
});
it('rejects github app setup states for the wrong callback action', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
cacheGithubAppSetupState('manifest-state', 'manifest', $this->githubApp);
cacheGithubAppSetupState('install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=manifest-state&setup_action=install&installation_id=123456')
->assertNotFound();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=install-state&code=real-code')
->assertNotFound();
Http::assertNothingSent();
});
it('allows github app install callbacks for repository update setup actions', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=update&installation_id=111111')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(111111);
});
it('redirects github app repository update callbacks without a matching source to the sources page', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=update&installation_id=123456')
->assertRedirect(route('source.all'));
Http::assertNothingSent();
});
it('rejects github app install callbacks for unknown setup actions', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=remove&installation_id=123456')
->assertUnprocessable();
Http::assertNothingSent();
});
it('rejects github app setup states from another team', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$otherTeam = Team::factory()->create();
$otherGithubApp = GithubApp::create([
'name' => 'Other GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $otherTeam->id,
'is_system_wide' => false,
]);
cacheGithubAppSetupState('other-team-state', 'manifest', $otherGithubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=other-team-state&code=real-code')
->assertForbidden();
Http::assertNothingSent();
});
it('rejects an installation id that github does not confirm belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerificationFailure();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=999999')
->assertForbidden();
$this->githubApp->refresh();
@@ -235,21 +324,39 @@ it('sets installation id when github confirms it belongs to the app', function (
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('rejects replayed github app install states', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertNotFound();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('allows reinstalling an already configured github app installation id', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=222222')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();