fix(github): derive API URLs from GitHub HTML hosts (#10610)
This commit is contained in:
commit
2caa5e67ff
12 changed files with 681 additions and 46 deletions
|
|
@ -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.'],
|
'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).'],
|
'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',
|
'is_system_wide',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$request->merge([
|
||||||
|
'organization' => normalizeGithubOrganization($request->input('organization')),
|
||||||
|
]);
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'organization' => 'nullable|string|max:255',
|
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
|
||||||
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
|
||||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||||
'custom_user' => 'nullable|string|max:255',
|
'custom_user' => 'nullable|string|max:255',
|
||||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||||
|
|
@ -252,7 +256,9 @@ public function create_github_app(Request $request)
|
||||||
'uuid' => Str::uuid(),
|
'uuid' => Str::uuid(),
|
||||||
'name' => $request->input('name'),
|
'name' => $request->input('name'),
|
||||||
'organization' => $request->input('organization'),
|
'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'),
|
'html_url' => $request->input('html_url'),
|
||||||
'custom_user' => $request->input('custom_user', 'git'),
|
'custom_user' => $request->input('custom_user', 'git'),
|
||||||
'custom_port' => $request->input('custom_port', 22),
|
'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);
|
$payload = $request->only($allowedFields);
|
||||||
|
|
||||||
|
if (array_key_exists('organization', $payload)) {
|
||||||
|
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the request
|
// Validate the request
|
||||||
$rules = [];
|
$rules = [];
|
||||||
if (isset($payload['name'])) {
|
if (isset($payload['name'])) {
|
||||||
$rules['name'] = 'string';
|
$rules['name'] = 'string';
|
||||||
}
|
}
|
||||||
if (isset($payload['organization'])) {
|
if (isset($payload['organization'])) {
|
||||||
$rules['organization'] = 'nullable|string';
|
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
|
||||||
}
|
}
|
||||||
if (isset($payload['api_url'])) {
|
if (isset($payload['api_url'])) {
|
||||||
$rules['api_url'] = ['url', new SafeExternalUrl];
|
$rules['api_url'] = ['url', new SafeExternalUrl];
|
||||||
|
|
@ -639,6 +649,13 @@ public function update_github_app(Request $request, $github_app_id)
|
||||||
], 422);
|
], 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
|
// Handle private_key_uuid -> private_key_id conversion
|
||||||
if (isset($payload['private_key_uuid'])) {
|
if (isset($payload['private_key_uuid'])) {
|
||||||
$privateKey = PrivateKey::where('team_id', $teamId)
|
$privateKey = PrivateKey::where('team_id', $teamId)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Change extends Component
|
class Change extends Component
|
||||||
|
|
@ -78,11 +79,13 @@ class Change extends Component
|
||||||
|
|
||||||
public string $activeTab = 'general';
|
public string $activeTab = 'general';
|
||||||
|
|
||||||
|
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
|
||||||
|
|
||||||
protected function rules(): array
|
protected function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'organization' => 'nullable|string',
|
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
|
||||||
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||||
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||||
'customUser' => 'required|string',
|
'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()
|
public function boot()
|
||||||
{
|
{
|
||||||
if ($this->github_app) {
|
if ($this->github_app) {
|
||||||
|
|
@ -119,6 +135,11 @@ private function syncData(bool $toModel = false): void
|
||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
// Sync TO model (before save)
|
// 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->name = $this->name;
|
||||||
$this->github_app->organization = $this->organization;
|
$this->github_app->organization = $this->organization;
|
||||||
$this->github_app->api_url = $this->apiUrl;
|
$this->github_app->api_url = $this->apiUrl;
|
||||||
|
|
@ -294,11 +315,14 @@ public function mount()
|
||||||
|
|
||||||
public function getGithubAppNameUpdatePath()
|
public function getGithubAppNameUpdatePath()
|
||||||
{
|
{
|
||||||
if (str($this->github_app->organization)->isNotEmpty()) {
|
$name = encodeGithubPathSegment($this->github_app->name);
|
||||||
return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$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()
|
public function updateGithubAppName()
|
||||||
|
|
@ -341,11 +365,17 @@ public function submit()
|
||||||
$this->authorize('update', $this->github_app);
|
$this->authorize('update', $this->github_app);
|
||||||
|
|
||||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
$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->validate();
|
||||||
|
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->github_app->save();
|
$this->github_app->save();
|
||||||
$this->dispatch('success', 'Github App updated.');
|
$this->dispatch('success', 'Github App updated.');
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\GithubApp;
|
use App\Models\GithubApp;
|
||||||
use App\Rules\SafeExternalUrl;
|
use App\Rules\SafeExternalUrl;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Create extends Component
|
class Create extends Component
|
||||||
|
|
@ -25,19 +26,39 @@ class Create extends Component
|
||||||
|
|
||||||
public bool $is_system_wide = false;
|
public bool $is_system_wide = false;
|
||||||
|
|
||||||
|
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->name = substr(generate_random_name(), 0, 30);
|
$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()
|
public function createGitHubApp()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('createAnyResource');
|
$this->authorize('createAnyResource');
|
||||||
|
|
||||||
|
$this->organization = normalizeGithubOrganization($this->organization);
|
||||||
|
$this->api_url = filled($this->api_url)
|
||||||
|
? $this->api_url
|
||||||
|
: githubApiUrlFromHtmlUrl($this->html_url);
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'organization' => 'nullable|string',
|
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
|
||||||
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||||
'custom_user' => 'required|string',
|
'custom_user' => 'required|string',
|
||||||
|
|
@ -60,6 +81,8 @@ public function createGitHubApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,141 @@
|
||||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
||||||
use Lcobucci\JWT\Token\Builder;
|
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
|
function assertGithubClockInSync(string $apiUrl): void
|
||||||
{
|
{
|
||||||
$response = Http::get("{$apiUrl}/zen");
|
$response = Http::get("{$apiUrl}/zen");
|
||||||
|
|
@ -192,13 +327,17 @@ function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
|
||||||
|
|
||||||
function getInstallationPath(GithubApp $source): string
|
function getInstallationPath(GithubApp $source): string
|
||||||
{
|
{
|
||||||
$name = str(Str::kebab($source->name));
|
$name = encodeGithubPathSegment(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';
|
|
||||||
$state = Str::random(64);
|
$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), [
|
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
||||||
'action' => 'install',
|
'action' => 'install',
|
||||||
|
|
@ -206,25 +345,19 @@ function getInstallationPath(GithubApp $source): string
|
||||||
'team_id' => $source->team_id,
|
'team_id' => $source->team_id,
|
||||||
], now()->addMinutes(60));
|
], now()->addMinutes(60));
|
||||||
|
|
||||||
if ($usesDataResidencyPath) {
|
return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]);
|
||||||
$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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPermissionsPath(GithubApp $source)
|
function getPermissionsPath(GithubApp $source)
|
||||||
{
|
{
|
||||||
$github = GithubApp::where('uuid', $source->uuid)->first();
|
$name = encodeGithubPathSegment(Str::kebab($source->name));
|
||||||
$name = str(Str::kebab($github->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)
|
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
|
||||||
|
|
|
||||||
|
|
@ -7527,7 +7527,6 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"required": [
|
"required": [
|
||||||
"name",
|
"name",
|
||||||
"api_url",
|
|
||||||
"html_url",
|
"html_url",
|
||||||
"app_id",
|
"app_id",
|
||||||
"installation_id",
|
"installation_id",
|
||||||
|
|
|
||||||
|
|
@ -4868,7 +4868,6 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
- api_url
|
|
||||||
- html_url
|
- html_url
|
||||||
- app_id
|
- app_id
|
||||||
- installation_id
|
- installation_id
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,8 @@ function createGithubApp(webhook_endpoint, use_custom_webhook_endpoint, custom_w
|
||||||
baseUrl = devWebhook;
|
baseUrl = devWebhook;
|
||||||
}
|
}
|
||||||
const webhookBaseUrl = `${baseUrl}/webhooks`;
|
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 = {
|
const default_permissions = {
|
||||||
contents: 'read',
|
contents: 'read',
|
||||||
metadata: 'read',
|
metadata: 'read',
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,56 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\GithubApp;
|
use App\Models\GithubApp;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||||
|
|
||||||
// Create a team with owner
|
// Create a team with owner
|
||||||
$this->team = Team::factory()->create();
|
$this->team = Team::factory()->create();
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
|
||||||
// Create an API token for the user
|
// 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;
|
$this->bearerToken = $this->token->plainTextToken;
|
||||||
|
|
||||||
// Create a private key for the team
|
// Create a private key for the team
|
||||||
$this->privateKey = PrivateKey::create([
|
$this->privateKey = PrivateKey::create([
|
||||||
'name' => 'Test Key',
|
'name' => 'Test Key',
|
||||||
'private_key' => 'test-private-key-content',
|
'private_key' => validGithubAppsApiPrivateKey(),
|
||||||
'team_id' => $this->team->id,
|
'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 () {
|
describe('GET /api/v1/github-apps', function () {
|
||||||
test('returns 401 when not authenticated', function () {
|
test('returns 401 when not authenticated', function () {
|
||||||
$response = $this->getJson('/api/v1/github-apps');
|
$response = $this->getJson('/api/v1/github-apps');
|
||||||
|
|
@ -118,7 +143,8 @@
|
||||||
$otherTeam = Team::factory()->create();
|
$otherTeam = Team::factory()->create();
|
||||||
$otherUser = User::factory()->create();
|
$otherUser = User::factory()->create();
|
||||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
$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
|
// System-wide apps should be visible to other teams
|
||||||
$response = $this->withHeaders([
|
$response = $this->withHeaders([
|
||||||
|
|
@ -152,7 +178,7 @@
|
||||||
$otherTeam = Team::factory()->create();
|
$otherTeam = Team::factory()->create();
|
||||||
$otherPrivateKey = PrivateKey::create([
|
$otherPrivateKey = PrivateKey::create([
|
||||||
'name' => 'Other Key',
|
'name' => 'Other Key',
|
||||||
'private_key' => 'other-key',
|
'private_key' => validGithubAppsApiPrivateKey(),
|
||||||
'team_id' => $otherTeam->id,
|
'team_id' => $otherTeam->id,
|
||||||
]);
|
]);
|
||||||
GithubApp::create([
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,21 @@ function validPrivateKey(): string
|
||||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
|
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 () {
|
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
|
||||||
$githubApp = new GithubApp;
|
$githubApp = new GithubApp;
|
||||||
$githubApp->forceFill([
|
$githubApp->forceFill([
|
||||||
|
|
@ -404,6 +419,49 @@ function validPrivateKey(): string
|
||||||
expect($githubApp->private_key_id)->toBe($privateKey->id);
|
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 () {
|
test('validation allows nullable values for app configuration', function () {
|
||||||
$githubApp = GithubApp::create([
|
$githubApp = GithubApp::create([
|
||||||
'name' => 'Test GitHub App',
|
'name' => 'Test GitHub App',
|
||||||
|
|
@ -556,4 +614,43 @@ function validPrivateKey(): string
|
||||||
->and($githubApp->metadata)->toBe('read')
|
->and($githubApp->metadata)->toBe('read')
|
||||||
->and($githubApp->pull_requests)->toBe('write');
|
->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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@
|
||||||
Livewire::test(Create::class)
|
Livewire::test(Create::class)
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->set('name', 'enterprise-app')
|
->set('name', 'enterprise-app')
|
||||||
->set('api_url', 'https://github.enterprise.com/api/v3')
|
->set('api_url', 'https://github.ghe.com/api/v3')
|
||||||
->set('html_url', 'https://github.enterprise.com')
|
->set('html_url', 'https://github.ghe.com')
|
||||||
->set('custom_user', 'git-custom')
|
->set('custom_user', 'git-custom')
|
||||||
->set('custom_port', 2222)
|
->set('custom_port', 2222)
|
||||||
->call('createGitHubApp')
|
->call('createGitHubApp')
|
||||||
|
|
@ -82,12 +82,28 @@
|
||||||
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
|
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
|
||||||
|
|
||||||
expect($githubApp)->not->toBeNull();
|
expect($githubApp)->not->toBeNull();
|
||||||
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
|
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
|
||||||
expect($githubApp->html_url)->toBe('https://github.enterprise.com');
|
expect($githubApp->html_url)->toBe('https://github.ghe.com');
|
||||||
expect($githubApp->custom_user)->toBe('git-custom');
|
expect($githubApp->custom_user)->toBe('git-custom');
|
||||||
expect($githubApp->custom_port)->toBe(2222);
|
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 () {
|
test('validates required fields', function () {
|
||||||
Livewire::test(Create::class)
|
Livewire::test(Create::class)
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ function authenticateGithubSetupCallbackTest(object $test): void
|
||||||
session(['currentTeam' => $test->team]);
|
session(['currentTeam' => $test->team]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeGithubManifestConversion(): void
|
function fakeGithubManifestConversion(string $apiUrl = 'https://api.github.com'): void
|
||||||
{
|
{
|
||||||
$key = openssl_pkey_new([
|
$key = openssl_pkey_new([
|
||||||
'private_key_bits' => 2048,
|
'private_key_bits' => 2048,
|
||||||
|
|
@ -54,7 +54,7 @@ function fakeGithubManifestConversion(): void
|
||||||
|
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'https://api.github.com/app-manifests/*/conversions' => Http::response([
|
"{$apiUrl}/app-manifests/*/conversions" => Http::response([
|
||||||
'id' => 987654,
|
'id' => 987654,
|
||||||
'slug' => 'attacker-controlled-app',
|
'slug' => 'attacker-controlled-app',
|
||||||
'client_id' => 'new-client-id',
|
'client_id' => 'new-client-id',
|
||||||
|
|
@ -86,14 +86,14 @@ function configureGithubAppCredentials(GithubApp $githubApp): void
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeGithubInstallationVerification(int $appId): void
|
function fakeGithubInstallationVerification(int $appId, string $apiUrl = 'https://api.github.com'): void
|
||||||
{
|
{
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
Http::fake([
|
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(),
|
'Date' => now()->toRfc7231String(),
|
||||||
]),
|
]),
|
||||||
'https://api.github.com/app/installations/*' => Http::response([
|
"{$apiUrl}/app/installations/*" => Http::response([
|
||||||
'id' => 555,
|
'id' => 555,
|
||||||
'app_id' => $appId,
|
'app_id' => $appId,
|
||||||
], 200),
|
], 200),
|
||||||
|
|
@ -183,6 +183,21 @@ function fakeGithubInstallationVerificationFailure(): void
|
||||||
->and($this->githubApp->private_key_id)->not->toBeNull();
|
->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 () {
|
it('rejects replayed github app manifest states', function () {
|
||||||
authenticateGithubSetupCallbackTest($this);
|
authenticateGithubSetupCallbackTest($this);
|
||||||
fakeGithubManifestConversion();
|
fakeGithubManifestConversion();
|
||||||
|
|
@ -333,6 +348,23 @@ function fakeGithubInstallationVerificationFailure(): void
|
||||||
expect($this->githubApp->installation_id)->toBe(123456);
|
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 () {
|
it('rejects replayed github app install states', function () {
|
||||||
authenticateGithubSetupCallbackTest($this);
|
authenticateGithubSetupCallbackTest($this);
|
||||||
configureGithubAppCredentials($this->githubApp);
|
configureGithubAppCredentials($this->githubApp);
|
||||||
|
|
|
||||||
86
tests/Unit/GithubUrlHelpersTest.php
Normal file
86
tests/Unit/GithubUrlHelpersTest.php
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue