Files
coolify/tests/Feature/GithubAppInstallationTokenTest.php
Andras BacsaiandClaude Opus 5.5 00678a12f0 fix(deployments): retry transient GitHub token and clone failures
Retry the installation token request on GitHub 5xx and connection errors,
and report the HTTP status when GitHub sends no error message. Retry the
repository import when SSH drops the connection (exit code 255), after
stopping the interrupted clone and removing the partial checkout.

Closes #8004

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:06:57 +02:00

122 lines
4.4 KiB
PHP

<?php
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
uses(RefreshDatabase::class);
beforeEach(function () {
Sleep::fake();
$this->team = Team::factory()->create();
openssl_pkey_export(openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]), $pemKey);
$this->githubApp = GithubApp::create([
'name' => 'Private GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => PrivateKey::create([
'name' => 'GitHub App Key',
'private_key' => $pemKey,
'team_id' => $this->team->id,
])->id,
'is_public' => false,
'team_id' => $this->team->id,
]);
Http::fake([
'https://api.github.com/zen' => fn () => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
]);
});
function fakeGithubInstallationTokenResponses(mixed $response): void
{
Http::fake(['https://api.github.com/app/installations/*/access_tokens' => $response]);
}
function fakeTwoGithubInstallationTokens(): void
{
fakeGithubInstallationTokenResponses(Http::sequence()
->push(['token' => 'first-token'], 201)
->push(['token' => 'second-token'], 201));
}
function githubInstallationTokenRequests(): int
{
return Http::recorded(fn (Request $request): bool => str_ends_with($request->url(), '/access_tokens'))->count();
}
it('reuses the installation token of the same GitHub App instance', function () {
fakeTwoGithubInstallationTokens();
expect(generateGithubInstallationToken($this->githubApp))->toBe('first-token')
->and(generateGithubInstallationToken($this->githubApp))->toBe('first-token')
->and(githubInstallationTokenRequests())->toBe(1);
});
it('requests a new installation token after the in-memory token expires', function () {
fakeTwoGithubInstallationTokens();
generateGithubInstallationToken($this->githubApp);
$this->travel(51)->minutes();
expect(generateGithubInstallationToken($this->githubApp))->toBe('second-token')
->and(githubInstallationTokenRequests())->toBe(2);
});
it('requests a new installation token when the GitHub App installation changes', function () {
fakeTwoGithubInstallationTokens();
generateGithubInstallationToken($this->githubApp);
$this->githubApp->installation_id = 11111;
expect(generateGithubInstallationToken($this->githubApp))->toBe('second-token')
->and(githubInstallationTokenRequests())->toBe(2);
});
it('does not share the installation token between GitHub App instances', function () {
fakeTwoGithubInstallationTokens();
generateGithubInstallationToken($this->githubApp);
expect(generateGithubInstallationToken(GithubApp::find($this->githubApp->id)))->toBe('second-token')
->and(githubInstallationTokenRequests())->toBe(2);
});
it('retries the installation token request when GitHub returns a server error', function () {
fakeGithubInstallationTokenResponses(Http::sequence()
->push('<html>Bad Gateway</html>', 502)
->push(['token' => 'retried-token'], 201));
expect(generateGithubInstallationToken($this->githubApp))->toBe('retried-token')
->and(githubInstallationTokenRequests())->toBe(2);
});
it('does not retry the installation token request when GitHub rejects it', function () {
fakeGithubInstallationTokenResponses(Http::response(['message' => 'Bad credentials'], 401));
expect(fn () => generateGithubInstallationToken($this->githubApp))
->toThrow(RuntimeException::class, 'Failed to get installation token for Private GitHub App with error: Bad credentials')
->and(githubInstallationTokenRequests())->toBe(1);
});
it('reports the HTTP status when GitHub keeps failing without an error message', function () {
fakeGithubInstallationTokenResponses(Http::response('<html>Service Unavailable</html>', 503));
expect(fn () => generateGithubInstallationToken($this->githubApp))
->toThrow(RuntimeException::class, 'Failed to get installation token for Private GitHub App with error: HTTP 503')
->and(githubInstallationTokenRequests())->toBe(3);
});