fix(github): derive API URLs from GitHub HTML hosts (#10610)

This commit is contained in:
Andras Bacsai 2026-07-07 12:29:15 +02:00 committed by GitHub
commit 2caa5e67ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 681 additions and 46 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'],
),
),
],
@ -205,10 +205,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',
@ -252,7 +256,9 @@ public function create_github_app(Request $request)
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'),
'api_url' => filled($request->input('api_url'))
? $request->input('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),
@ -589,13 +595,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];
@ -639,6 +649,13 @@ 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']) && ! filled($payload['api_url'] ?? null)) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
}
// Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId)

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Change extends Component
@ -78,11 +79,13 @@ class Change extends Component
public string $activeTab = 'general';
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
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',
@ -103,6 +106,19 @@ protected function rules(): array
];
}
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl)
|| $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl);
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
}
}
public function boot()
{
if ($this->github_app) {
@ -119,6 +135,11 @@ private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->github_app->name = $this->name;
$this->github_app->organization = $this->organization;
$this->github_app->api_url = $this->apiUrl;
@ -294,11 +315,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}";
}
public function updateGithubAppName()
@ -341,11 +365,17 @@ 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 = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->validate();
$this->syncData(true);
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -5,6 +5,7 @@
use App\Models\GithubApp;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Create extends Component
@ -25,19 +26,39 @@ class Create extends Component
public bool $is_system_wide = false;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
public function mount()
{
$this->name = substr(generate_random_name(), 0, 30);
}
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url)
|| $this->api_url === githubApiUrlFromHtmlUrl($this->html_url);
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
}
}
public function createGitHubApp()
{
try {
$this->authorize('createAnyResource');
$this->organization = normalizeGithubOrganization($this->organization);
$this->api_url = filled($this->api_url)
? $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',
@ -60,6 +81,8 @@ public function createGitHubApp()
}
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -14,6 +14,141 @@
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\Builder;
/**
* Extract and normalize the hostname from a GitHub URL.
*
* @param string|null $url The URL to parse
* @return string|null The lowercase hostname, or null if the URL is blank or has no parseable host
*/
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);
}
/**
* Build the scheme://host[:port] origin for a GitHub URL.
*
* This helper fails explicitly for blank, scheme-less, or malformed input when
* githubUrlHost() cannot parse a host, because returning the original input
* would not be a valid origin. Callers should pass already-validated URLs.
*
* @param string $url The URL to derive the origin from
* @return string The normalized origin
*
* @throws InvalidArgumentException When the URL does not contain a parseable scheme and host
*/
function githubUrlOrigin(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = githubUrlHost($url);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || blank($scheme) || ! $host) {
throw new InvalidArgumentException('GitHub URL must include a valid scheme and host.');
}
return $scheme.'://'.$host.($port ? ":{$port}" : '');
}
/**
* Determine whether the URL points at github.com.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubDotComHost(?string $htmlUrl): bool
{
return githubUrlHost($htmlUrl) === 'github.com';
}
/**
* Determine whether the URL points at a *.ghe.com GitHub Enterprise Cloud host.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGheDotComHost(?string $htmlUrl): bool
{
$host = githubUrlHost($htmlUrl);
return is_string($host)
&& Str::endsWith($host, '.ghe.com')
&& ! Str::startsWith($host, 'api.');
}
/**
* Determine whether the URL belongs to GitHub's cloud family (github.com or *.ghe.com).
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubCloudFamilyHost(?string $htmlUrl): bool
{
return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl);
}
/**
* Determine whether the URL belongs to a self-hosted GitHub Enterprise Server.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubEnterpriseServerHost(?string $htmlUrl): bool
{
return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl);
}
/**
* Derive the GitHub REST API base URL from a GitHub HTML URL.
*
* @param string $htmlUrl The GitHub HTML URL
* @return string The API base URL (api.github.com, api.<host> for *.ghe.com, or <origin>/api/v3 for GHES)
*/
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';
}
/**
* Normalize a GitHub organization slug by trimming surrounding slashes and whitespace.
*
* @param string|null $organization The raw organization value
* @return string|null The trimmed organization, or null when blank
*/
function normalizeGithubOrganization(?string $organization): ?string
{
if (blank($organization)) {
return null;
}
return trim((string) $organization, "/ \t\n\r\0\x0B");
}
/**
* URL-encode a single GitHub path segment.
*
* @param string $segment The raw path segment
* @return string The raw-URL-encoded segment
*/
function encodeGithubPathSegment(string $segment): string
{
return rawurlencode($segment);
}
function assertGithubClockInSync(string $apiUrl): void
{
$response = Http::get("{$apiUrl}/zen");
@ -192,13 +327,17 @@ function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
function getInstallationPath(GithubApp $source): string
{
$name = str(Str::kebab($source->name));
$baseUrl = rtrim($source->html_url, '/');
$host = parse_url($source->html_url, PHP_URL_HOST);
$host = blank($host) ? null : Str::lower($host);
$usesDataResidencyPath = filled($host) && Str::endsWith($host, '.ghe.com');
$installation_path = $host === 'github.com' || $usesDataResidencyPath ? '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',
@ -206,25 +345,19 @@ function getInstallationPath(GithubApp $source): string
'team_id' => $source->team_id,
], now()->addMinutes(60));
if ($usesDataResidencyPath) {
$organization = str($source->organization)->trim('/');
if ($organization->isNotEmpty()) {
$organization = rawurlencode((string) $organization);
return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]);
}
}
return "$baseUrl/$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

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

View file

@ -4868,7 +4868,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

@ -1,31 +1,56 @@
<?php
use App\Models\GithubApp;
use App\Models\InstanceSettings;
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);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->token = $this->user->createToken('test-token');
$this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $this->team->id,
]);
});
/**
* Generate a temporary 2048-bit RSA private key for GitHub Apps API tests.
*
* Generated in-process so tests do not depend on external files or secrets;
* the key is used only as a signing fixture and is never persisted.
*
* @return string PEM-encoded RSA private key
*/
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');
@ -118,7 +143,8 @@
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-token');
// System-wide apps should be visible to other teams
$response = $this->withHeaders([
@ -152,7 +178,7 @@
$otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([
'name' => 'Other Key',
'private_key' => 'other-key',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $otherTeam->id,
]);
GithubApp::create([
@ -220,3 +246,179 @@
]);
});
});
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://github.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.github.ghe.com',
'html_url' => 'https://github.ghe.com',
]);
});
test('preserves provided api url when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.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://github.com/api/v3',
'html_url' => 'https://github.com',
]);
});
test('preserves provided 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://github.com',
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
test('preserves provided api url when updating api url only', function () {
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.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,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
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

@ -251,6 +251,21 @@ function validPrivateKey(): string
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
});
test('ghe.com installation path encodes plus signs in the 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,
]);
$installationUrl = getInstallationPath($githubApp);
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
});
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
@ -404,6 +419,49 @@ function validPrivateKey(): string
expect($githubApp->private_key_id)->toBe($privateKey->id);
});
test('preserves custom ghe api url when saving github app settings', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://github.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://github.ghe.com')
->set('apiUrl', 'https://github.ghe.com/api/v3')
->call('submit')
->assertDispatched('success')
->assertSet('apiUrl', 'https://github.ghe.com/api/v3');
$githubApp->refresh();
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
});
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',
@ -556,4 +614,43 @@ function validPrivateKey(): string
->and($githubApp->metadata)->toBe('read')
->and($githubApp->pull_requests)->toBe('write');
});
test('sync name uses normalized resolvable 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.github.ghe.com',
'html_url' => 'https://github.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.github.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.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.github.ghe.com/app');
});
});

View file

@ -72,8 +72,8 @@
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('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.ghe.com')
->set('custom_user', 'git-custom')
->set('custom_port', 2222)
->call('createGitHubApp')
@ -82,12 +82,28 @@
$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->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.ghe.com');
expect($githubApp->custom_user)->toBe('git-custom');
expect($githubApp->custom_port)->toBe(2222);
});
test('preserves custom github enterprise api url when creating github app', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'ghe-custom-api-app')
->set('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.ghe.com')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'ghe-custom-api-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.ghe.com');
});
test('validates required fields', function () {
Livewire::test(Create::class)
->assertSuccessful()

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,86 @@
<?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('rejects malformed urls when deriving an origin', function (string $url) {
expect(fn () => githubUrlOrigin($url))->toThrow(InvalidArgumentException::class);
})->with([
'blank' => [''],
'scheme-less' => ['github.company.internal'],
'malformed' => ['not-a-url'],
]);
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');
});