Merge remote-tracking branch 'origin/next' into ghe-support-helpers
This commit is contained in:
commit
a7dddccd2e
3 changed files with 240 additions and 65 deletions
|
|
@ -8,11 +8,8 @@
|
|||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
|
||||
class Change extends Component
|
||||
|
|
@ -216,6 +213,8 @@ public function checkPermissions()
|
|||
return;
|
||||
}
|
||||
|
||||
syncGithubAppName($this->github_app);
|
||||
|
||||
GithubAppPermissionJob::dispatchSync($this->github_app);
|
||||
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->syncData(false);
|
||||
|
|
@ -314,64 +313,34 @@ public function getGithubAppNameUpdatePath()
|
|||
return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}";
|
||||
}
|
||||
|
||||
private function generateGithubJwt($private_key, $app_id): string
|
||||
{
|
||||
$configuration = Configuration::forAsymmetricSigner(
|
||||
new Sha256,
|
||||
InMemory::plainText($private_key),
|
||||
InMemory::plainText($private_key)
|
||||
);
|
||||
|
||||
$now = time();
|
||||
|
||||
return $configuration->builder()
|
||||
->issuedBy((string) $app_id)
|
||||
->permittedFor($this->github_app->api_url)
|
||||
->identifiedBy((string) $now)
|
||||
->issuedAt(new \DateTimeImmutable("@{$now}"))
|
||||
->expiresAt(new \DateTimeImmutable('@'.($now + 600)))
|
||||
->getToken($configuration->signer(), $configuration->signingKey())
|
||||
->toString();
|
||||
}
|
||||
|
||||
public function updateGithubAppName()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->github_app);
|
||||
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id);
|
||||
$this->github_app->app_id = $this->appId;
|
||||
$this->github_app->private_key_id = $this->privateKeyId;
|
||||
$this->github_app->unsetRelation('privateKey');
|
||||
|
||||
if (! $privateKey) {
|
||||
if (! $this->appId) {
|
||||
$this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) {
|
||||
$this->dispatch('error', 'No private key found for this GitHub App.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id);
|
||||
$appSlug = syncGithubAppName($this->github_app, true);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
'Authorization' => "Bearer {$jwt}",
|
||||
])->get("{$this->github_app->api_url}/app");
|
||||
|
||||
if ($response->successful()) {
|
||||
$app_data = $response->json();
|
||||
$app_slug = $app_data['slug'] ?? null;
|
||||
|
||||
if ($app_slug) {
|
||||
$this->github_app->name = $app_slug;
|
||||
$this->name = str($app_slug)->kebab();
|
||||
$privateKey->name = "github-app-{$app_slug}";
|
||||
$privateKey->save();
|
||||
$this->github_app->save();
|
||||
$this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.');
|
||||
} else {
|
||||
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
|
||||
}
|
||||
if ($appSlug) {
|
||||
$this->name = str($appSlug)->kebab();
|
||||
$this->dispatch('success', 'GitHub App name and private key name synchronized successfully.');
|
||||
} else {
|
||||
$error_message = $response->json()['message'] ?? 'Unknown error';
|
||||
$this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}");
|
||||
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -391,6 +360,8 @@ public function submit()
|
|||
$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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
@ -147,9 +148,9 @@ function encodeGithubPathSegment(string $segment): string
|
|||
return rawurlencode($segment);
|
||||
}
|
||||
|
||||
function generateGithubToken(GithubApp $source, string $type)
|
||||
function assertGithubClockInSync(string $apiUrl): void
|
||||
{
|
||||
$response = Http::get("{$source->api_url}/zen");
|
||||
$response = Http::get("{$apiUrl}/zen");
|
||||
$serverTime = CarbonImmutable::now()->setTimezone('UTC');
|
||||
$githubTime = Carbon::parse($response->header('date'));
|
||||
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
|
||||
|
|
@ -163,6 +164,11 @@ function generateGithubToken(GithubApp $source, string $type)
|
|||
'Please synchronize your system clock.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateGithubToken(GithubApp $source, string $type)
|
||||
{
|
||||
assertGithubClockInSync($source->api_url);
|
||||
|
||||
$signingKey = InMemory::plainText($source->privateKey->private_key);
|
||||
$algorithm = new Sha256;
|
||||
|
|
@ -251,6 +257,73 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
|||
];
|
||||
}
|
||||
|
||||
function generateGithubAppJwt(string $privateKey, string|int $appId): string
|
||||
{
|
||||
$algorithm = new Sha256;
|
||||
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
|
||||
$now = CarbonImmutable::now()->setTimezone('UTC');
|
||||
$now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s'));
|
||||
|
||||
return $tokenBuilder
|
||||
->issuedBy((string) $appId)
|
||||
->issuedAt($now->modify('-1 minute'))
|
||||
->expiresAt($now->modify('+8 minutes'))
|
||||
->getToken($algorithm, InMemory::plainText($privateKey))
|
||||
->toString();
|
||||
}
|
||||
|
||||
function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
|
||||
{
|
||||
try {
|
||||
if (blank($source->app_id) || blank($source->private_key_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id);
|
||||
|
||||
if (! $privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assertGithubClockInSync($source->api_url);
|
||||
|
||||
$jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
'Authorization' => "Bearer {$jwt}",
|
||||
])->get("{$source->api_url}/app");
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.'));
|
||||
}
|
||||
|
||||
$appSlug = data_get($response->json(), 'slug');
|
||||
|
||||
if (blank($appSlug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$source->name = $appSlug;
|
||||
|
||||
if ($source->exists) {
|
||||
$source->save();
|
||||
}
|
||||
|
||||
$privateKey->name = "github-app-{$appSlug}";
|
||||
$privateKey->save();
|
||||
|
||||
return $appSlug;
|
||||
} catch (Throwable $e) {
|
||||
if ($throw) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getInstallationPath(GithubApp $source): string
|
||||
{
|
||||
$name = encodeGithubPathSegment(Str::kebab($source->name));
|
||||
|
|
|
|||
|
|
@ -147,7 +147,111 @@ function validPrivateKey(): string
|
|||
]);
|
||||
});
|
||||
|
||||
test('ghe dot com installation path includes encoded organization segment', function () {
|
||||
test('ghe.com installation path uses github cloud owner scoped route', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => 'acme-enterprise',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$installState = $query['state'] ?? null;
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/provided-github-app/installations/new?')
|
||||
->and($installState)->not->toBeEmpty()
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => 123,
|
||||
'team_id' => 456,
|
||||
]);
|
||||
});
|
||||
|
||||
test('installation path is pure and never calls github or mutates the app', function () {
|
||||
Http::fake();
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'github-app-local-name',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Local Display Name',
|
||||
'organization' => 'acme-enterprise',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/local-display-name/installations/new?')
|
||||
->and($githubApp->refresh()->name)->toBe('Local Display Name')
|
||||
->and($privateKey->refresh()->name)->toBe('github-app-local-name');
|
||||
});
|
||||
|
||||
test('syncGithubAppName persists the github slug and renames the private key', function () {
|
||||
Http::fake([
|
||||
'*/app' => Http::response(['slug' => 'actual-github-slug']),
|
||||
'*/zen' => Http::response('Keep it logically awesome.'),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'github-app-local-name',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Local Display Name',
|
||||
'organization' => 'acme-enterprise',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
$appSlug = syncGithubAppName($githubApp, true);
|
||||
|
||||
expect($appSlug)->toBe('actual-github-slug')
|
||||
->and($githubApp->refresh()->name)->toBe('actual-github-slug')
|
||||
->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug');
|
||||
});
|
||||
|
||||
test('ghe.com installation path encodes the organization segment', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => '/acme enterprise/',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
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,
|
||||
|
|
@ -157,8 +261,24 @@ function validPrivateKey(): string
|
|||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
expect(getInstallationPath($githubApp))
|
||||
->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
|
||||
$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([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => null,
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/provided-github-app/installations/new?');
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
|
|
@ -221,6 +341,10 @@ function validPrivateKey(): string
|
|||
});
|
||||
|
||||
test('can mount with fully configured github app', function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
|
|
@ -255,6 +379,10 @@ function validPrivateKey(): string
|
|||
});
|
||||
|
||||
test('can update github app from null to valid values', function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
|
|
@ -291,10 +419,10 @@ function validPrivateKey(): string
|
|||
expect($githubApp->private_key_id)->toBe($privateKey->id);
|
||||
});
|
||||
|
||||
test('normalizes ghe dot com api url when saving github app settings', function () {
|
||||
test('normalizes resolvable 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',
|
||||
'api_url' => 'https://github.ghe.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
|
|
@ -305,14 +433,14 @@ function validPrivateKey(): string
|
|||
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')
|
||||
->set('htmlUrl', 'https://github.ghe.com')
|
||||
->set('apiUrl', 'https://github.ghe.com/api/v3')
|
||||
->call('submit')
|
||||
->assertDispatched('success')
|
||||
->assertSet('apiUrl', 'https://api.octocorp.ghe.com');
|
||||
->assertSet('apiUrl', 'https://api.github.ghe.com');
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->api_url)->toBe('https://api.octocorp.ghe.com');
|
||||
expect($githubApp->api_url)->toBe('https://api.github.ghe.com');
|
||||
});
|
||||
|
||||
test('rejects invalid github organization values', function () {
|
||||
|
|
@ -487,7 +615,7 @@ function validPrivateKey(): string
|
|||
->and($githubApp->pull_requests)->toBe('write');
|
||||
});
|
||||
|
||||
test('sync name uses normalized ghe dot com api url', function () {
|
||||
test('sync name uses normalized resolvable ghe dot com api url', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
|
|
@ -496,8 +624,8 @@ function validPrivateKey(): string
|
|||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'api_url' => 'https://api.github.ghe.com',
|
||||
'html_url' => 'https://github.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
|
|
@ -509,7 +637,10 @@ function validPrivateKey(): string
|
|||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.octocorp.ghe.com/app' => Http::response([
|
||||
'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',
|
||||
]),
|
||||
]);
|
||||
|
|
@ -520,6 +651,6 @@ function validPrivateKey(): string
|
|||
->call('updateGithubAppName')
|
||||
->assertDispatched('success');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue