diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 150743f99..16dfc8136 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -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) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 682333aa6..a24ed9ce3 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -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); } diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index ec2ba3f08..6a5bf6e60 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -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); } diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index f5d7a6e9c..052ce6d9f 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -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. for *.ghe.com, or /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) diff --git a/openapi.json b/openapi.json index cdb7e736e..704221aed 100644 --- a/openapi.json +++ b/openapi.json @@ -7527,7 +7527,6 @@ "schema": { "required": [ "name", - "api_url", "html_url", "app_id", "installation_id", diff --git a/openapi.yaml b/openapi.yaml index f2775c8d7..25cb39378 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4868,7 +4868,6 @@ paths: schema: required: - name - - api_url - html_url - app_id - installation_id diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index dc9560a1f..051f9809f 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -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', diff --git a/tests/Feature/Api/GithubAppsListApiTest.php b/tests/Feature/Api/GithubAppsListApiTest.php index a6ce59dca..8f3e6dac5 100644 --- a/tests/Feature/Api/GithubAppsListApiTest.php +++ b/tests/Feature/Api/GithubAppsListApiTest.php @@ -1,31 +1,56 @@ 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'); + }); +}); diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index 70d4e101d..40a4e7314 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -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'); + }); }); diff --git a/tests/Feature/Application/GithubSourceCreateTest.php b/tests/Feature/Application/GithubSourceCreateTest.php index 82343092c..a3eb13d54 100644 --- a/tests/Feature/Application/GithubSourceCreateTest.php +++ b/tests/Feature/Application/GithubSourceCreateTest.php @@ -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() diff --git a/tests/Feature/Security/GithubAppSetupCallbackTest.php b/tests/Feature/Security/GithubAppSetupCallbackTest.php index f56a77d5f..9e3f8ea81 100644 --- a/tests/Feature/Security/GithubAppSetupCallbackTest.php +++ b/tests/Feature/Security/GithubAppSetupCallbackTest.php @@ -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); diff --git a/tests/Unit/GithubUrlHelpersTest.php b/tests/Unit/GithubUrlHelpersTest.php new file mode 100644 index 000000000..dcace7ebe --- /dev/null +++ b/tests/Unit/GithubUrlHelpersTest.php @@ -0,0 +1,86 @@ +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'); +});