mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-19 07:35:25 +00:00
fix(github): derive app API URLs from HTML hosts
Normalize GitHub organization values and derive API URLs for GitHub.com, GHE.com, and enterprise hosts when creating or updating GitHub Apps.
This commit is contained in:
@@ -129,7 +129,7 @@ class GithubController extends Controller
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
|
||||
],
|
||||
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -204,10 +204,14 @@ class GithubController extends Controller
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$request->merge([
|
||||
'organization' => normalizeGithubOrganization($request->input('organization')),
|
||||
]);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'organization' => 'nullable|string|max:255',
|
||||
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
|
||||
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
|
||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'custom_user' => 'nullable|string|max:255',
|
||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||
@@ -250,8 +254,8 @@ class GithubController extends Controller
|
||||
$payload = [
|
||||
'uuid' => Str::uuid(),
|
||||
'name' => $request->input('name'),
|
||||
'organization' => $request->input('organization'),
|
||||
'api_url' => $request->input('api_url'),
|
||||
'organization' => normalizeGithubOrganization($request->input('organization')),
|
||||
'api_url' => githubApiUrlFromHtmlUrl($request->input('html_url')),
|
||||
'html_url' => $request->input('html_url'),
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
@@ -587,13 +591,17 @@ class GithubController extends Controller
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
if (array_key_exists('organization', $payload)) {
|
||||
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string';
|
||||
}
|
||||
if (isset($payload['organization'])) {
|
||||
$rules['organization'] = 'nullable|string';
|
||||
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = ['url', new SafeExternalUrl];
|
||||
@@ -637,6 +645,15 @@ class GithubController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (array_key_exists('organization', $payload)) {
|
||||
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
|
||||
}
|
||||
if (isset($payload['html_url'])) {
|
||||
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
|
||||
} elseif (isset($payload['api_url'])) {
|
||||
$payload['api_url'] = githubApiUrlFromHtmlUrl($githubApp->html_url);
|
||||
}
|
||||
|
||||
// Handle private_key_uuid -> private_key_id conversion
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$privateKey = PrivateKey::where('team_id', $teamId)
|
||||
|
||||
@@ -86,7 +86,7 @@ class Change extends Component
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string',
|
||||
'organization' => 'nullable|string',
|
||||
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
|
||||
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'customUser' => 'required|string',
|
||||
@@ -107,6 +107,11 @@ class Change extends Component
|
||||
];
|
||||
}
|
||||
|
||||
public function updatedHtmlUrl(): void
|
||||
{
|
||||
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
|
||||
}
|
||||
|
||||
public function boot()
|
||||
{
|
||||
if ($this->github_app) {
|
||||
@@ -123,6 +128,9 @@ class Change extends Component
|
||||
{
|
||||
if ($toModel) {
|
||||
// Sync TO model (before save)
|
||||
$this->organization = normalizeGithubOrganization($this->organization);
|
||||
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
|
||||
|
||||
$this->github_app->name = $this->name;
|
||||
$this->github_app->organization = $this->organization;
|
||||
$this->github_app->api_url = $this->apiUrl;
|
||||
@@ -296,11 +304,14 @@ class Change extends Component
|
||||
|
||||
public function getGithubAppNameUpdatePath()
|
||||
{
|
||||
if (str($this->github_app->organization)->isNotEmpty()) {
|
||||
return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}";
|
||||
$name = encodeGithubPathSegment($this->github_app->name);
|
||||
$organization = normalizeGithubOrganization($this->github_app->organization);
|
||||
|
||||
if (filled($organization)) {
|
||||
return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}";
|
||||
}
|
||||
|
||||
return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}";
|
||||
return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}";
|
||||
}
|
||||
|
||||
private function generateGithubJwt($private_key, $app_id): string
|
||||
@@ -315,7 +326,7 @@ class Change extends Component
|
||||
|
||||
return $configuration->builder()
|
||||
->issuedBy((string) $app_id)
|
||||
->permittedFor('https://api.github.com')
|
||||
->permittedFor($this->github_app->api_url)
|
||||
->identifiedBy((string) $now)
|
||||
->issuedAt(new \DateTimeImmutable("@{$now}"))
|
||||
->expiresAt(new \DateTimeImmutable('@'.($now + 600)))
|
||||
@@ -373,6 +384,8 @@ class Change extends Component
|
||||
$this->authorize('update', $this->github_app);
|
||||
|
||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->organization = normalizeGithubOrganization($this->organization);
|
||||
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
|
||||
$this->validate();
|
||||
|
||||
$this->syncData(true);
|
||||
|
||||
@@ -30,14 +30,22 @@ class Create extends Component
|
||||
$this->name = substr(generate_random_name(), 0, 30);
|
||||
}
|
||||
|
||||
public function updatedHtmlUrl(): void
|
||||
{
|
||||
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
|
||||
}
|
||||
|
||||
public function createGitHubApp()
|
||||
{
|
||||
try {
|
||||
$this->authorize('createAnyResource');
|
||||
|
||||
$this->organization = normalizeGithubOrganization($this->organization);
|
||||
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'organization' => 'nullable|string',
|
||||
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
|
||||
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'custom_user' => 'required|string',
|
||||
|
||||
@@ -13,6 +13,85 @@ use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
||||
use Lcobucci\JWT\Token\Builder;
|
||||
|
||||
function githubUrlHost(?string $url): ?string
|
||||
{
|
||||
if (blank($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
|
||||
if (! is_string($host) || blank($host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($host);
|
||||
}
|
||||
|
||||
function githubUrlOrigin(string $url): string
|
||||
{
|
||||
$scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https';
|
||||
$host = githubUrlHost($url);
|
||||
$port = parse_url($url, PHP_URL_PORT);
|
||||
|
||||
if (! $host) {
|
||||
return rtrim($url, '/');
|
||||
}
|
||||
|
||||
return $scheme.'://'.$host.($port ? ":{$port}" : '');
|
||||
}
|
||||
|
||||
function isGithubDotComHost(?string $htmlUrl): bool
|
||||
{
|
||||
return githubUrlHost($htmlUrl) === 'github.com';
|
||||
}
|
||||
|
||||
function isGheDotComHost(?string $htmlUrl): bool
|
||||
{
|
||||
$host = githubUrlHost($htmlUrl);
|
||||
|
||||
return is_string($host)
|
||||
&& Str::endsWith($host, '.ghe.com')
|
||||
&& ! Str::startsWith($host, 'api.');
|
||||
}
|
||||
|
||||
function isGithubCloudFamilyHost(?string $htmlUrl): bool
|
||||
{
|
||||
return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl);
|
||||
}
|
||||
|
||||
function isGithubEnterpriseServerHost(?string $htmlUrl): bool
|
||||
{
|
||||
return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl);
|
||||
}
|
||||
|
||||
function githubApiUrlFromHtmlUrl(string $htmlUrl): string
|
||||
{
|
||||
if (isGithubDotComHost($htmlUrl)) {
|
||||
return 'https://api.github.com';
|
||||
}
|
||||
|
||||
if (isGheDotComHost($htmlUrl)) {
|
||||
return 'https://api.'.githubUrlHost($htmlUrl);
|
||||
}
|
||||
|
||||
return githubUrlOrigin($htmlUrl).'/api/v3';
|
||||
}
|
||||
|
||||
function normalizeGithubOrganization(?string $organization): ?string
|
||||
{
|
||||
if (blank($organization)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) $organization, "/ \t\n\r\0\x0B");
|
||||
}
|
||||
|
||||
function encodeGithubPathSegment(string $segment): string
|
||||
{
|
||||
return rawurlencode($segment);
|
||||
}
|
||||
|
||||
function generateGithubToken(GithubApp $source, string $type)
|
||||
{
|
||||
$response = Http::get("{$source->api_url}/zen");
|
||||
@@ -119,9 +198,17 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||
|
||||
function getInstallationPath(GithubApp $source): string
|
||||
{
|
||||
$name = str(Str::kebab($source->name));
|
||||
$installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
|
||||
$name = encodeGithubPathSegment(Str::kebab($source->name));
|
||||
$state = Str::random(64);
|
||||
$organization = normalizeGithubOrganization($source->organization);
|
||||
|
||||
if (isGithubEnterpriseServerHost($source->html_url)) {
|
||||
$path = "github-apps/{$name}";
|
||||
} elseif (isGheDotComHost($source->html_url) && filled($organization)) {
|
||||
$path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}";
|
||||
} else {
|
||||
$path = "apps/{$name}";
|
||||
}
|
||||
|
||||
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
||||
'action' => 'install',
|
||||
@@ -129,15 +216,19 @@ function getInstallationPath(GithubApp $source): string
|
||||
'team_id' => $source->team_id,
|
||||
], now()->addMinutes(60));
|
||||
|
||||
return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
|
||||
return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]);
|
||||
}
|
||||
|
||||
function getPermissionsPath(GithubApp $source)
|
||||
{
|
||||
$github = GithubApp::where('uuid', $source->uuid)->first();
|
||||
$name = str(Str::kebab($github->name));
|
||||
$name = encodeGithubPathSegment(Str::kebab($source->name));
|
||||
$organization = normalizeGithubOrganization($source->organization);
|
||||
|
||||
return "$github->html_url/settings/apps/$name/permissions";
|
||||
if (filled($organization)) {
|
||||
return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions";
|
||||
}
|
||||
|
||||
return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions";
|
||||
}
|
||||
|
||||
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
|
||||
|
||||
@@ -7439,7 +7439,6 @@
|
||||
"schema": {
|
||||
"required": [
|
||||
"name",
|
||||
"api_url",
|
||||
"html_url",
|
||||
"app_id",
|
||||
"installation_id",
|
||||
|
||||
@@ -4806,7 +4806,6 @@ paths:
|
||||
schema:
|
||||
required:
|
||||
- name
|
||||
- api_url
|
||||
- html_url
|
||||
- app_id
|
||||
- installation_id
|
||||
|
||||
@@ -373,7 +373,8 @@
|
||||
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',
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -26,6 +27,18 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
function validGithubAppsApiPrivateKey(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/github-apps', function () {
|
||||
test('returns 401 when not authenticated', function () {
|
||||
$response = $this->getJson('/api/v1/github-apps');
|
||||
@@ -220,3 +233,129 @@ describe('GET /api/v1/github-apps', function () {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub app API url normalization', function () {
|
||||
test('normalizes ghe dot com api url when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => '/octocorp/',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment([
|
||||
'organization' => 'octocorp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('normalizes ghe dot com api url when updating github apps', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://github.company.internal/api/v3',
|
||||
'html_url' => 'https://github.company.internal',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'api_url' => 'https://octocorp.ghe.com/api/v3',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.api_url', 'https://api.octocorp.ghe.com');
|
||||
});
|
||||
|
||||
test('rejects invalid organization when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => 'octo/corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['organization']);
|
||||
});
|
||||
|
||||
test('loads repositories and branches through normalized ghe dot com api url', function () {
|
||||
$this->privateKey->update([
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/app/installations/67890/access_tokens' => Http::response([
|
||||
'token' => 'installation-token',
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/installation/repositories*' => Http::response([
|
||||
'repositories' => [
|
||||
['name' => 'repo', 'full_name' => 'octocorp/repo'],
|
||||
],
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/repos/octocorp/repo/branches' => Http::response([
|
||||
['name' => 'main'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('repositories.0.name', 'repo');
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories/octocorp/repo/branches")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('branches.0.name', 'main');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/installation/repositories?per_page=100&page=1');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +147,20 @@ describe('GitHub Source Change Component', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('ghe dot com installation path includes encoded organization segment', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'Provided GitHub App',
|
||||
'organization' => 'octo+corp',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
expect(getInstallationPath($githubApp))
|
||||
->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
@@ -277,6 +291,49 @@ describe('GitHub Source Change Component', function () {
|
||||
expect($githubApp->private_key_id)->toBe($privateKey->id);
|
||||
});
|
||||
|
||||
test('normalizes ghe dot com api url when saving github app settings', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://octocorp.ghe.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->set('htmlUrl', 'https://octocorp.ghe.com')
|
||||
->set('apiUrl', 'https://octocorp.ghe.com/api/v3')
|
||||
->call('submit')
|
||||
->assertDispatched('success')
|
||||
->assertSet('apiUrl', 'https://api.octocorp.ghe.com');
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->api_url)->toBe('https://api.octocorp.ghe.com');
|
||||
});
|
||||
|
||||
test('rejects invalid github organization values', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->set('organization', 'octo/corp')
|
||||
->call('submit')
|
||||
->assertHasErrors(['organization']);
|
||||
});
|
||||
|
||||
test('validation allows nullable values for app configuration', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
@@ -429,4 +486,40 @@ describe('GitHub Source Change Component', function () {
|
||||
->and($githubApp->metadata)->toBe('read')
|
||||
->and($githubApp->pull_requests)->toBe('write');
|
||||
});
|
||||
|
||||
test('sync name uses normalized ghe dot com api url', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.octocorp.ghe.com/app' => Http::response([
|
||||
'slug' => 'octocorp-app',
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->call('updateGithubAppName')
|
||||
->assertDispatched('success');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ function authenticateGithubSetupCallbackTest(object $test): void
|
||||
session(['currentTeam' => $test->team]);
|
||||
}
|
||||
|
||||
function fakeGithubManifestConversion(): void
|
||||
function fakeGithubManifestConversion(string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
@@ -54,7 +54,7 @@ function fakeGithubManifestConversion(): void
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/app-manifests/*/conversions' => Http::response([
|
||||
"{$apiUrl}/app-manifests/*/conversions" => Http::response([
|
||||
'id' => 987654,
|
||||
'slug' => 'attacker-controlled-app',
|
||||
'client_id' => 'new-client-id',
|
||||
@@ -86,14 +86,14 @@ function configureGithubAppCredentials(GithubApp $githubApp): void
|
||||
])->save();
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerification(int $appId): void
|
||||
function fakeGithubInstallationVerification(int $appId, string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
"{$apiUrl}/zen" => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response([
|
||||
"{$apiUrl}/app/installations/*" => Http::response([
|
||||
'id' => 555,
|
||||
'app_id' => $appId,
|
||||
], 200),
|
||||
@@ -183,6 +183,21 @@ it('configures an unbound github app with a valid one-time manifest state', func
|
||||
->and($this->githubApp->private_key_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('converts ghe dot com app manifests through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
fakeGithubManifestConversion('https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app-manifests/real-code/conversions');
|
||||
});
|
||||
|
||||
it('rejects replayed github app manifest states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
@@ -333,6 +348,23 @@ it('sets installation id when github confirms it belongs to the app', function (
|
||||
expect($this->githubApp->installation_id)->toBe(123456);
|
||||
});
|
||||
|
||||
it('verifies ghe dot com installations through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id, 'https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/zen');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app/installations/123456');
|
||||
});
|
||||
|
||||
it('rejects replayed github app install states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('classifies github hosts', function () {
|
||||
expect(isGithubDotComHost('https://github.com'))->toBeTrue()
|
||||
->and(isGheDotComHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://github.com'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://github.company.internal'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://octocorp.ghe.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('derives github api urls from html urls', function (string $htmlUrl, string $apiUrl) {
|
||||
expect(githubApiUrlFromHtmlUrl($htmlUrl))->toBe($apiUrl);
|
||||
})->with([
|
||||
'github.com' => ['https://github.com', 'https://api.github.com'],
|
||||
'ghe.com data residency' => ['https://octocorp.ghe.com', 'https://api.octocorp.ghe.com'],
|
||||
'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'],
|
||||
]);
|
||||
|
||||
it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill(array_merge([
|
||||
'id' => 123,
|
||||
'name' => 'Coolify Test App',
|
||||
'team_id' => 456,
|
||||
], $attributes));
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$state = $query['state'] ?? null;
|
||||
|
||||
expect($installationUrl)->toStartWith($expectedPrefix)
|
||||
->and($state)->not->toBeEmpty()
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $state)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => 123,
|
||||
'team_id' => 456,
|
||||
]);
|
||||
})->with([
|
||||
'github.com' => [
|
||||
['html_url' => 'https://github.com'],
|
||||
'https://github.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com organization' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => 'octo-corp'],
|
||||
'https://octocorp.ghe.com/apps/octo-corp/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com blank organization fallback' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => null],
|
||||
'https://octocorp.ghe.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'github enterprise server' => [
|
||||
['html_url' => 'https://github.company.internal', 'organization' => 'octo-corp'],
|
||||
'https://github.company.internal/github-apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
]);
|
||||
|
||||
it('encodes organization path segments in settings links', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'name' => 'coolify-app',
|
||||
'organization' => 'octo+corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => 123,
|
||||
]);
|
||||
|
||||
expect(getPermissionsPath($githubApp))->toBe('https://octocorp.ghe.com/organizations/octo%2Bcorp/settings/apps/coolify-app/permissions');
|
||||
});
|
||||
Reference in New Issue
Block a user