mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 16:17:10 +00:00
fix(github): derive app API URLs from HTML hosts
Normalize GitHub organization values and derive API URLs for GitHub.com, GHE.com, and enterprise hosts when creating or updating GitHub Apps.
This commit is contained in:
1 parent
a9d9bfdbe3
commit
281184c040
11 files changed
+496
-26
No files matched your search
@@ -5,6 +5,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -26,6 +27,18 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
function validGithubAppsApiPrivateKey(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/github-apps', function () {
|
||||
test('returns 401 when not authenticated', function () {
|
||||
$response = $this->getJson('/api/v1/github-apps');
|
||||
@@ -220,3 +233,129 @@ describe('GET /api/v1/github-apps', function () {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub app API url normalization', function () {
|
||||
test('normalizes ghe dot com api url when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => '/octocorp/',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment([
|
||||
'organization' => 'octocorp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('normalizes ghe dot com api url when updating github apps', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://github.company.internal/api/v3',
|
||||
'html_url' => 'https://github.company.internal',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'api_url' => 'https://octocorp.ghe.com/api/v3',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.api_url', 'https://api.octocorp.ghe.com');
|
||||
});
|
||||
|
||||
test('rejects invalid organization when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => 'octo/corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['organization']);
|
||||
});
|
||||
|
||||
test('loads repositories and branches through normalized ghe dot com api url', function () {
|
||||
$this->privateKey->update([
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/app/installations/67890/access_tokens' => Http::response([
|
||||
'token' => 'installation-token',
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/installation/repositories*' => Http::response([
|
||||
'repositories' => [
|
||||
['name' => 'repo', 'full_name' => 'octocorp/repo'],
|
||||
],
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/repos/octocorp/repo/branches' => Http::response([
|
||||
['name' => 'main'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('repositories.0.name', 'repo');
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories/octocorp/repo/branches")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('branches.0.name', 'main');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/installation/repositories?per_page=100&page=1');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches');
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,20 @@ describe('GitHub Source Change Component', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('ghe dot com installation path includes encoded organization segment', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'Provided GitHub App',
|
||||
'organization' => 'octo+corp',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
expect(getInstallationPath($githubApp))
|
||||
->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
@@ -277,6 +291,49 @@ describe('GitHub Source Change Component', function () {
|
||||
expect($githubApp->private_key_id)->toBe($privateKey->id);
|
||||
});
|
||||
|
||||
test('normalizes ghe dot com api url when saving github app settings', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://octocorp.ghe.com/api/v3',
|
||||
'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('htmlUrl', 'https://octocorp.ghe.com')
|
||||
->set('apiUrl', 'https://octocorp.ghe.com/api/v3')
|
||||
->call('submit')
|
||||
->assertDispatched('success')
|
||||
->assertSet('apiUrl', 'https://api.octocorp.ghe.com');
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->api_url)->toBe('https://api.octocorp.ghe.com');
|
||||
});
|
||||
|
||||
test('rejects invalid github organization values', 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,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->set('organization', 'octo/corp')
|
||||
->call('submit')
|
||||
->assertHasErrors(['organization']);
|
||||
});
|
||||
|
||||
test('validation allows nullable values for app configuration', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
@@ -429,4 +486,40 @@ describe('GitHub Source Change Component', function () {
|
||||
->and($githubApp->metadata)->toBe('read')
|
||||
->and($githubApp->pull_requests)->toBe('write');
|
||||
});
|
||||
|
||||
test('sync name uses normalized ghe dot com api url', 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.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.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.octocorp.ghe.com/app' => Http::response([
|
||||
'slug' => 'octocorp-app',
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->call('updateGithubAppName')
|
||||
->assertDispatched('success');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app');
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ function authenticateGithubSetupCallbackTest(object $test): void
|
||||
session(['currentTeam' => $test->team]);
|
||||
}
|
||||
|
||||
function fakeGithubManifestConversion(): void
|
||||
function fakeGithubManifestConversion(string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
@@ -54,7 +54,7 @@ function fakeGithubManifestConversion(): void
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/app-manifests/*/conversions' => Http::response([
|
||||
"{$apiUrl}/app-manifests/*/conversions" => Http::response([
|
||||
'id' => 987654,
|
||||
'slug' => 'attacker-controlled-app',
|
||||
'client_id' => 'new-client-id',
|
||||
@@ -86,14 +86,14 @@ function configureGithubAppCredentials(GithubApp $githubApp): void
|
||||
])->save();
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerification(int $appId): void
|
||||
function fakeGithubInstallationVerification(int $appId, string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
"{$apiUrl}/zen" => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response([
|
||||
"{$apiUrl}/app/installations/*" => Http::response([
|
||||
'id' => 555,
|
||||
'app_id' => $appId,
|
||||
], 200),
|
||||
@@ -183,6 +183,21 @@ it('configures an unbound github app with a valid one-time manifest state', func
|
||||
->and($this->githubApp->private_key_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('converts ghe dot com app manifests through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
fakeGithubManifestConversion('https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app-manifests/real-code/conversions');
|
||||
});
|
||||
|
||||
it('rejects replayed github app manifest states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
@@ -333,6 +348,23 @@ it('sets installation id when github confirms it belongs to the app', function (
|
||||
expect($this->githubApp->installation_id)->toBe(123456);
|
||||
});
|
||||
|
||||
it('verifies ghe dot com installations through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id, 'https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
|
||||
|
||||
$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]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/zen');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app/installations/123456');
|
||||
});
|
||||
|
||||
it('rejects replayed github app install states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('classifies github hosts', function () {
|
||||
expect(isGithubDotComHost('https://github.com'))->toBeTrue()
|
||||
->and(isGheDotComHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://github.com'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://github.company.internal'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://octocorp.ghe.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('derives github api urls from html urls', function (string $htmlUrl, string $apiUrl) {
|
||||
expect(githubApiUrlFromHtmlUrl($htmlUrl))->toBe($apiUrl);
|
||||
})->with([
|
||||
'github.com' => ['https://github.com', 'https://api.github.com'],
|
||||
'ghe.com data residency' => ['https://octocorp.ghe.com', 'https://api.octocorp.ghe.com'],
|
||||
'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'],
|
||||
]);
|
||||
|
||||
it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill(array_merge([
|
||||
'id' => 123,
|
||||
'name' => 'Coolify Test App',
|
||||
'team_id' => 456,
|
||||
], $attributes));
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$state = $query['state'] ?? null;
|
||||
|
||||
expect($installationUrl)->toStartWith($expectedPrefix)
|
||||
->and($state)->not->toBeEmpty()
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $state)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => 123,
|
||||
'team_id' => 456,
|
||||
]);
|
||||
})->with([
|
||||
'github.com' => [
|
||||
['html_url' => 'https://github.com'],
|
||||
'https://github.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com organization' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => 'octo-corp'],
|
||||
'https://octocorp.ghe.com/apps/octo-corp/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com blank organization fallback' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => null],
|
||||
'https://octocorp.ghe.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'github enterprise server' => [
|
||||
['html_url' => 'https://github.company.internal', 'organization' => 'octo-corp'],
|
||||
'https://github.company.internal/github-apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
]);
|
||||
|
||||
it('encodes organization path segments in settings links', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'name' => 'coolify-app',
|
||||
'organization' => 'octo+corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => 123,
|
||||
]);
|
||||
|
||||
expect(getPermissionsPath($githubApp))->toBe('https://octocorp.ghe.com/organizations/octo%2Bcorp/settings/apps/coolify-app/permissions');
|
||||
});
|
||||
Reference in new issue
Block a user