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:
Andras Bacsai 2026-06-09 18:31:06 +02:00
parent a9d9bfdbe3
commit 281184c040
11 changed files with 496 additions and 26 deletions

View file

@ -129,7 +129,7 @@ public function list_github_apps(Request $request)
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
],
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
),
),
],
@ -204,10 +204,14 @@ public function create_github_app(Request $request)
'is_system_wide',
];
$request->merge([
'organization' => normalizeGithubOrganization($request->input('organization')),
]);
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
@ -250,8 +254,8 @@ public function create_github_app(Request $request)
$payload = [
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'),
'organization' => normalizeGithubOrganization($request->input('organization')),
'api_url' => githubApiUrlFromHtmlUrl($request->input('html_url')),
'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
@ -587,13 +591,17 @@ public function update_github_app(Request $request, $github_app_id)
$payload = $request->only($allowedFields);
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
// Validate the request
$rules = [];
if (isset($payload['name'])) {
$rules['name'] = 'string';
}
if (isset($payload['organization'])) {
$rules['organization'] = 'nullable|string';
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
}
if (isset($payload['api_url'])) {
$rules['api_url'] = ['url', new SafeExternalUrl];
@ -637,6 +645,15 @@ public function update_github_app(Request $request, $github_app_id)
], 422);
}
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
if (isset($payload['html_url'])) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
} elseif (isset($payload['api_url'])) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($githubApp->html_url);
}
// Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId)

View file

@ -86,7 +86,7 @@ protected function rules(): array
{
return [
'name' => 'required|string',
'organization' => 'nullable|string',
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'customUser' => 'required|string',
@ -107,6 +107,11 @@ protected function rules(): array
];
}
public function updatedHtmlUrl(): void
{
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
}
public function boot()
{
if ($this->github_app) {
@ -123,6 +128,9 @@ private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->github_app->name = $this->name;
$this->github_app->organization = $this->organization;
$this->github_app->api_url = $this->apiUrl;
@ -296,11 +304,14 @@ public function mount()
public function getGithubAppNameUpdatePath()
{
if (str($this->github_app->organization)->isNotEmpty()) {
return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}";
$name = encodeGithubPathSegment($this->github_app->name);
$organization = normalizeGithubOrganization($this->github_app->organization);
if (filled($organization)) {
return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}";
}
return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}";
return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}";
}
private function generateGithubJwt($private_key, $app_id): string
@ -315,7 +326,7 @@ private function generateGithubJwt($private_key, $app_id): string
return $configuration->builder()
->issuedBy((string) $app_id)
->permittedFor('https://api.github.com')
->permittedFor($this->github_app->api_url)
->identifiedBy((string) $now)
->issuedAt(new \DateTimeImmutable("@{$now}"))
->expiresAt(new \DateTimeImmutable('@'.($now + 600)))
@ -373,6 +384,8 @@ public function submit()
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->validate();
$this->syncData(true);

View file

@ -30,14 +30,22 @@ public function mount()
$this->name = substr(generate_random_name(), 0, 30);
}
public function updatedHtmlUrl(): void
{
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
}
public function createGitHubApp()
{
try {
$this->authorize('createAnyResource');
$this->organization = normalizeGithubOrganization($this->organization);
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
$this->validate([
'name' => 'required|string',
'organization' => 'nullable|string',
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'required|string',

View file

@ -13,6 +13,85 @@
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\Builder;
function githubUrlHost(?string $url): ?string
{
if (blank($url)) {
return null;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || blank($host)) {
return null;
}
return strtolower($host);
}
function githubUrlOrigin(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https';
$host = githubUrlHost($url);
$port = parse_url($url, PHP_URL_PORT);
if (! $host) {
return rtrim($url, '/');
}
return $scheme.'://'.$host.($port ? ":{$port}" : '');
}
function isGithubDotComHost(?string $htmlUrl): bool
{
return githubUrlHost($htmlUrl) === 'github.com';
}
function isGheDotComHost(?string $htmlUrl): bool
{
$host = githubUrlHost($htmlUrl);
return is_string($host)
&& Str::endsWith($host, '.ghe.com')
&& ! Str::startsWith($host, 'api.');
}
function isGithubCloudFamilyHost(?string $htmlUrl): bool
{
return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl);
}
function isGithubEnterpriseServerHost(?string $htmlUrl): bool
{
return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl);
}
function githubApiUrlFromHtmlUrl(string $htmlUrl): string
{
if (isGithubDotComHost($htmlUrl)) {
return 'https://api.github.com';
}
if (isGheDotComHost($htmlUrl)) {
return 'https://api.'.githubUrlHost($htmlUrl);
}
return githubUrlOrigin($htmlUrl).'/api/v3';
}
function normalizeGithubOrganization(?string $organization): ?string
{
if (blank($organization)) {
return null;
}
return trim((string) $organization, "/ \t\n\r\0\x0B");
}
function encodeGithubPathSegment(string $segment): string
{
return rawurlencode($segment);
}
function generateGithubToken(GithubApp $source, string $type)
{
$response = Http::get("{$source->api_url}/zen");
@ -119,9 +198,17 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
function getInstallationPath(GithubApp $source): string
{
$name = str(Str::kebab($source->name));
$installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
$name = encodeGithubPathSegment(Str::kebab($source->name));
$state = Str::random(64);
$organization = normalizeGithubOrganization($source->organization);
if (isGithubEnterpriseServerHost($source->html_url)) {
$path = "github-apps/{$name}";
} elseif (isGheDotComHost($source->html_url) && filled($organization)) {
$path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}";
} else {
$path = "apps/{$name}";
}
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
'action' => 'install',
@ -129,15 +216,19 @@ function getInstallationPath(GithubApp $source): string
'team_id' => $source->team_id,
], now()->addMinutes(60));
return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]);
}
function getPermissionsPath(GithubApp $source)
{
$github = GithubApp::where('uuid', $source->uuid)->first();
$name = str(Str::kebab($github->name));
$name = encodeGithubPathSegment(Str::kebab($source->name));
$organization = normalizeGithubOrganization($source->organization);
return "$github->html_url/settings/apps/$name/permissions";
if (filled($organization)) {
return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions";
}
return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions";
}
function loadRepositoryByPage(GithubApp $source, string $token, int $page)

View file

@ -7439,7 +7439,6 @@
"schema": {
"required": [
"name",
"api_url",
"html_url",
"app_id",
"installation_id",

View file

@ -4806,7 +4806,6 @@ paths:
schema:
required:
- name
- api_url
- html_url
- app_id
- installation_id

View file

@ -373,7 +373,8 @@ function createGithubApp(webhook_endpoint, use_custom_webhook_endpoint, custom_w
baseUrl = devWebhook;
}
const webhookBaseUrl = `${baseUrl}/webhooks`;
const path = organization ? `organizations/${organization}/settings/apps/new` : 'settings/apps/new';
const organizationPath = organization ? encodeURIComponent(organization.replace(/^\/+|\/+$/g, '')) : '';
const path = organizationPath ? `organizations/${organizationPath}/settings/apps/new` : 'settings/apps/new';
const default_permissions = {
contents: 'read',
metadata: 'read',

View file

@ -5,6 +5,7 @@
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 @@
]);
});
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('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');
});
});

View file

@ -147,6 +147,20 @@ function validPrivateKey(): string
]);
});
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 @@ function validPrivateKey(): string
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 @@ function validPrivateKey(): string
->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');
});
});

View file

@ -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 @@ function fakeGithubInstallationVerificationFailure(): void
->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 @@ function fakeGithubInstallationVerificationFailure(): void
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);

View file

@ -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');
});