Files
coolify/tests/Feature/GithubRunnerSettingsTest.php
Andras Bacsai b7a2c75739 fix(github): check clock skew only from failed GitHub App responses
Stop calling /zen before each installation token request. A short
network problem to GitHub failed deployments with a cURL timeout on
/zen. assertGithubClockInSync now takes the failed response and reads
its Date header, so the "System time is out of sync" error still shows
when GitHub rejects the JWT.

Call it on failed JWT requests in the webhook installation check, the
permission job, the source connection test, and the server transfer
checklist. Remove the /zen fakes from the tests.
2026-11-03 19:29:45 +01:00

761 lines
34 KiB
PHP

<?php
use App\Enums\GithubRunnerDockerMode;
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Livewire\Server\GithubRunnerExecutions;
use App\Livewire\Server\GithubRunners;
use App\Livewire\Server\Show;
use App\Livewire\Server\SysboxStatus;
use App\Livewire\Source\Github\Change;
use App\Livewire\Source\Github\Create;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$this->server = settingsTestServer($this->team, 'build');
$this->githubApp = settingsTestGithubApp($this->team, 3333);
});
afterEach(function () {
Server::flushIdentityMap();
});
function settingsTestServer(Team $team, string $role): Server
{
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'server_role' => $role,
'is_build_server' => $role === 'build',
'is_swarm_worker' => false,
'force_disabled' => false,
]);
return $server->fresh();
}
function settingsTestGithubApp(Team $team, int $appId, array $attributes = []): GithubApp
{
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($rsaKey, $pemKey);
$privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $team->id]);
return GithubApp::create([
'name' => "settings-app-{$appId}",
'organization' => 'acme',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => $appId,
'installation_id' => $appId + 1,
'webhook_secret' => 'secret',
'private_key_id' => $privateKey->id,
'team_id' => $team->id,
'is_system_wide' => false,
'organization_self_hosted_runners' => 'write',
'actions' => 'read',
'webhook_events' => ['workflow_job'],
...$attributes,
]);
}
/**
* Attributes of a GitHub App that is missing every runner permission.
*/
function unreadyRunnerApp(): array
{
return ['organization_self_hosted_runners' => null, 'actions' => null, 'webhook_events' => ['push']];
}
function fakeRunnerGroupApi(GithubApp $githubApp, int $createStatus = 201): void
{
Http::fake([
"https://api.github.com/app/installations/{$githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runner-groups' => fn ($request) => $request->method() === 'POST'
? Http::response($createStatus === 201 ? ['id' => 55, 'default' => false] : ['message' => 'Upgrade to create runner groups'], $createStatus)
: Http::response(['runner_groups' => [['id' => 1, 'name' => 'Default', 'default' => true]]], 200),
]);
}
describe('runner settings page', function () {
it('saves the configuration and creates a runner group that public repositories can use', function () {
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('labels', 'Coolify, self-hosted, gpu')
->set('memoryLimit', '4G')
->call('submit')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Recent runners');
$config = GithubRunnerConfig::sole();
expect($config->server_id)->toBe($this->server->id)
->and($config->labels)->toBe(['coolify', 'gpu'])
->and($config->memory_limit)->toBe('4g')
->and($config->docker_mode)->toBe(GithubRunnerDockerMode::None)
->and($this->githubApp->fresh()->runner_group_id)->toBe(55);
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& str_ends_with($request->url(), '/orgs/acme/actions/runner-groups')
&& $request['allows_public_repositories'] === true
&& $request['visibility'] === 'all');
expect($config->allow_pull_requests)->toBeFalse();
});
it('keeps the restrictions an organization admin set on an existing runner group', function () {
$this->githubApp->update(['runner_group_id' => 77]);
Http::fake([
"https://api.github.com/app/installations/{$this->githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runner-groups/77' => Http::response(['id' => 77, 'default' => false, 'visibility' => 'selected', 'allows_public_repositories' => false]),
]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->call('submit')
->assertHasNoErrors()
->assertDispatched('success');
expect($this->githubApp->fresh()->runner_group_id)->toBe(77)
->and(GithubRunnerConfig::count())->toBe(1);
Http::assertNotSent(fn ($request) => in_array($request->method(), ['PATCH', 'POST'], true)
&& str_contains($request->url(), '/runner-groups'));
});
it('saves whether runners take pull request jobs', function () {
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('allowPullRequests', true)
->call('submit')
->assertHasNoErrors()
->assertSet('allowPullRequests', true);
expect(GithubRunnerConfig::sole()->allow_pull_requests)->toBeTrue();
});
it('does not fall back to the Default runner group when group creation fails', function (int $status) {
fakeRunnerGroupApi($this->githubApp, $status);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->call('submit')
->assertNotDispatched('success');
expect($this->githubApp->fresh()->runner_group_id)->toBeNull()
->and(GithubRunnerConfig::count())->toBe(0);
Http::assertNotSent(fn ($request) => $request->method() === 'GET'
&& str_ends_with($request->url(), '/orgs/acme/actions/runner-groups'));
})->with([403, 401, 422, 429, 500]);
it('requires a custom label with safe characters', function (string $labels) {
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('labels', $labels)
->call('submit')
->assertHasErrors('labels');
expect(GithubRunnerConfig::count())->toBe(0);
})->with([
'only reserved labels' => ['self-hosted, linux, x64'],
'shell characters' => ['coolify;rm -rf /'],
]);
it('rejects resource limits that are not plain values', function (string $field, string $value) {
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set($field, $value)
->call('submit')
->assertHasErrors($field);
})->with([
'cpu' => ['cpuLimit', '2 --privileged'],
'memory' => ['memoryLimit', '4g; reboot'],
'image' => ['runnerImage', 'bad image; reboot'],
]);
it('rejects a GitHub App of another team', function () {
$otherApp = settingsTestGithubApp(Team::factory()->create(), 4444);
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $otherApp->id)
->call('submit')
->assertHasErrors('githubAppId');
expect(GithubRunnerConfig::count())->toBe(0);
Http::assertNothingSent();
});
it('does not let members change the configuration', function () {
Livewire::actingAs($this->member)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->call('submit')
->assertForbidden();
expect(GithubRunnerConfig::count())->toBe(0);
});
it('opens on Coolify Cloud because runners run on the user\'s own servers', function () {
config()->set('constants.coolify.self_hosted', false);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertNoRedirect()
->assertSee('GitHub Actions runners');
});
it('does not open the page for the Coolify host', function () {
$server = settingsTestServer($this->team, 'build');
$server->update(['ip' => 'host.docker.internal']);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
->assertRedirect(route('server.show', ['server_uuid' => $server->uuid]));
$this->actingAs($this->owner)
->get(route('server.show', ['server_uuid' => $server->uuid]))
->assertDontSee(route('server.github-runners', ['server_uuid' => $server->uuid]));
});
it('does not open the page for a server of another team', function () {
$otherServer = settingsTestServer(Team::factory()->create(), 'build');
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $otherServer->uuid])
->assertRedirect(route('server.index'));
});
it('asks for confirmation before it enables runners on a server that also runs resources', function () {
$server = settingsTestServer($this->team, 'both');
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
->assertSee('Enable runners on this server?')
->assertDontSee('Application builds')
->call('toggleEnabled')
->assertDispatched('success')
->assertSee('Runners share this server with your resources');
expect(GithubRunnerConfig::sole())->server_id->toBe($server->id)->is_enabled->toBeTrue();
});
it('refuses runners on a deployments only server', function () {
$server = settingsTestServer($this->team, 'deployment');
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
->assertSee('This server is not enabled for builds')
->assertDontSee('Recent runners')
->set('githubAppId', $this->githubApp->id)
->call('toggleEnabled')
->assertDispatched('error');
expect(GithubRunnerConfig::count())->toBe(0);
Http::assertNothingSent();
});
it('enables runners on a build server without a confirmation', function () {
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertDontSee('Enable runners on this server?')
->call('toggleEnabled')
->assertDispatched('success')
->assertSee('Application builds')
->assertDontSee('Runners share this server with your resources');
});
it('disables runners and removes only the idle ones', function () {
Queue::fake();
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
$idle = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Idle]);
$running = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Running]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertDispatched('success')
->assertSee('Runners are disabled')
->assertDontSee('Parallel runners')
->assertDontSee('Recent runners');
expect($config->fresh()->is_enabled)->toBeFalse()
->and($idle->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($running->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertPushed(CleanupGithubRunnerJob::class, 1);
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $idle->id);
});
it('enables disabled runners and starts the queued jobs', function () {
Queue::fake();
fakeRunnerGroupApi($this->githubApp);
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Queued]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertDispatched('success')
->assertDontSee('Runners are disabled')
->assertSee('Parallel runners');
expect($config->fresh()->is_enabled)->toBeTrue();
Queue::assertPushed(ProvisionGithubRunnerJob::class);
});
it('shows only the enable button before runners are enabled', function () {
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertSee('Runners are disabled')
->assertSee('Enable runners')
->assertDontSee('Parallel runners')
->assertDontSee('Recent runners')
->call('toggleEnabled')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Parallel runners');
$config = GithubRunnerConfig::sole();
expect($config->is_enabled)->toBeTrue()
->and($config->github_app_id)->toBe($this->githubApp->id)
->and($config->labels)->toBe(['coolify']);
});
it('enables runners with an App that is not ready without calling GitHub', function () {
Http::fake();
$this->githubApp->update(unreadyRunnerApp());
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Parallel runners')
->assertSee('The GitHub App is not ready');
expect(GithubRunnerConfig::sole()->is_enabled)->toBeTrue();
Http::assertNothingSent();
});
it('selects the first App that is ready for runners', function () {
$this->githubApp->update(['name' => 'a-first', ...unreadyRunnerApp()]);
$readyApp = settingsTestGithubApp($this->team, 4444, ['name' => 'b-ready']);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertSet('githubAppId', $readyApp->id);
});
it('saves settings with an App that is not ready without calling GitHub', function () {
Http::fake();
$this->githubApp->update(unreadyRunnerApp());
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('maxRunners', 4)
->call('submit')
->assertHasNoErrors()
->assertDispatched('success');
expect($config->fresh()->max_runners)->toBe(4);
Http::assertNothingSent();
});
it('keeps runners disabled when saving other settings', function () {
fakeRunnerGroupApi($this->githubApp);
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('maxRunners', 4)
->call('submit')
->assertDispatched('success');
expect($config->fresh()->is_enabled)->toBeFalse()
->and($config->fresh()->max_runners)->toBe(4);
});
it('does not let members disable runners', function () {
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
Livewire::actingAs($this->member)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertForbidden();
expect($config->fresh()->is_enabled)->toBeTrue();
});
});
describe('isolated Docker with Sysbox', function () {
it('does not save Isolated Docker while Sysbox is missing', function () {
fakeRunnerGroupApi($this->githubApp);
Process::fake(['*' => Process::result(output: 'io.containerd.runc.v2 runc')]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('dockerMode', 'sysbox')
->call('submit')
->assertHasErrors('dockerMode');
expect(GithubRunnerConfig::count())->toBe(0);
});
it('saves Isolated Docker when Sysbox is installed', function () {
fakeRunnerGroupApi($this->githubApp);
Process::fake(['*' => Process::result(output: 'io.containerd.runc.v2 runc sysbox-runc ')]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('dockerMode', 'sysbox')
->call('submit')
->assertHasNoErrors();
expect(GithubRunnerConfig::sole()->docker_mode->value)->toBe('sysbox');
});
it('checks that Sysbox can mount its FUSE file system', function () {
Process::fake(['*' => Process::result(output: '')]);
Livewire::withoutLazyLoading()
->actingAs($this->owner)
->test(SysboxStatus::class, ['server' => $this->server]);
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'test -x /usr/bin/fusermount3'));
});
it('shows the install button when Sysbox is missing', function () {
Process::fake(['*' => Process::result(output: '')]);
Livewire::withoutLazyLoading()
->actingAs($this->owner)
->test(SysboxStatus::class, ['server' => $this->server])
->assertSet('isInstalled', false)
->assertSee('Install Sysbox');
});
it('stops before the installation on servers without apt', function () {
Process::fake(['*' => Process::result(output: '')]);
Livewire::withoutLazyLoading()
->actingAs($this->owner)
->test(SysboxStatus::class, ['server' => $this->server])
->call('installSysbox')
->assertNotDispatched('activityMonitor');
Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'apt-get'), 1);
});
it('does not let members install Sysbox', function () {
Process::fake(['*' => Process::result(output: '')]);
Livewire::withoutLazyLoading()
->actingAs($this->member)
->test(SysboxStatus::class, ['server' => $this->server])
->call('installSysbox')
->assertForbidden();
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'apt-get'));
});
});
describe('runner executions list', function () {
beforeEach(function () {
Queue::fake();
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
$this->running = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running, 'repository_full_name' => 'acme/api']);
$this->queued = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Queued, 'repository_full_name' => 'acme/web']);
});
it('shows runners of the server and queued jobs of its GitHub App', function () {
$otherServer = settingsTestServer($this->team, 'build');
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'server_id' => $otherServer->id, 'trigger_workflow_job_id' => 3, 'status' => GithubRunnerStatus::Running, 'repository_full_name' => 'acme/other']);
Livewire::actingAs($this->owner)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->assertSee('acme/api')
->assertSee('acme/web')
->assertDontSee('acme/other');
});
it('cancels a running runner and queues its cleanup', function () {
Livewire::actingAs($this->owner)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->call('cancel', $this->running->id)
->assertDispatched('success');
expect($this->running->fresh()->status)->toBe(GithubRunnerStatus::Cancelled);
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $this->running->id);
});
it('does not let members cancel runners', function () {
Livewire::actingAs($this->member)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->call('cancel', $this->running->id)
->assertForbidden();
expect($this->running->fresh()->status)->toBe(GithubRunnerStatus::Running);
});
it('does not cancel runners of another server', function () {
$otherServer = settingsTestServer($this->team, 'build');
$other = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'server_id' => $otherServer->id, 'trigger_workflow_job_id' => 4, 'status' => GithubRunnerStatus::Running]);
Livewire::actingAs($this->owner)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->call('cancel', $other->id);
expect($other->fresh()->status)->toBe(GithubRunnerStatus::Running);
});
});
describe('build server role', function () {
beforeEach(function () {
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
});
it('changes the role in the UI while runners are enabled', function () {
Livewire::actingAs($this->owner)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->set('serverRole', 'both')
->call('requestServerRoleChange')
->assertNotDispatched('error')
->assertDispatched('open-server-role-confirmation')
->call('confirmServerRoleChange');
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
});
it('blocks a change to deployments only in the UI while runners are enabled', function () {
Livewire::actingAs($this->owner)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->set('serverRole', 'deployment')
->call('requestServerRoleChange')
->assertSet('serverRole', 'build')
->assertDispatched('error');
expect($this->server->settings->fresh()->server_role->value)->toBe('build');
});
it('blocks a change to deployments only through the API while runners are enabled', function () {
$token = $this->owner->createToken('runner-test', ['*']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
settingsTestServer($this->team, 'build');
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'deployment'])
->assertStatus(422)
->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you set this server to deployments only.');
});
it('changes the role through the API while runners are enabled', function () {
$token = $this->owner->createToken('runner-test', ['*']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'both'])
->assertSuccessful();
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
});
it('leaves dedicated runner servers out of application builds', function () {
expect(Server::buildServers($this->team->id)->pluck('id'))->toContain($this->server->id);
$this->server->githubRunnerConfig->update(['is_dedicated' => true]);
expect(Server::buildServers($this->team->id)->pluck('id'))->not->toContain($this->server->id);
$this->server->githubRunnerConfig->update(['is_enabled' => false]);
expect(Server::buildServers($this->team->id)->pluck('id'))->toContain($this->server->id);
});
});
describe('GitHub App runner permissions', function () {
it('stores runner permissions and webhook events from GitHub', function () {
Http::fake([
'https://api.github.com/app' => Http::response([
'permissions' => ['contents' => 'read', 'metadata' => 'read', 'organization_self_hosted_runners' => 'write', 'actions' => 'read'],
'events' => ['push', 'workflow_job'],
]),
]);
GithubAppPermissionJob::dispatchSync($this->githubApp);
$githubApp = $this->githubApp->fresh();
expect($githubApp->organization_self_hosted_runners)->toBe('write')
->and($githubApp->actions)->toBe('read')
->and($githubApp->webhook_events)->toBe(['push', 'workflow_job'])
->and($githubApp->missingRunnerRequirements())->toBe([]);
});
it('lists what an App needs before it can run jobs', function () {
$githubApp = settingsTestGithubApp($this->team, 5555, [...unreadyRunnerApp(), 'organization' => null]);
expect($githubApp->missingRunnerRequirements())->toHaveCount(4);
});
it('shows the runner permissions on the GitHub App permissions tab', function () {
$this->githubApp->update(['organization_self_hosted_runners' => 'write', 'webhook_events' => ['push']]);
Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])
->actingAs($this->owner)
->test(Change::class)
->set('activeTab', 'permissions')
->assertSee('GitHub Actions runners')
->assertSee('Not ready for runners')
->assertSee('Webhook event "Workflow job"')
->assertDontSee('Organization permission "Self-hosted runners": write');
});
it('shows the runner option disabled with a reason when the App has no organization', function () {
$personalApp = GithubApp::create(['name' => 'personal', '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' => $personalApp->uuid])
->actingAs($this->owner)
->test(Change::class)
->assertSee('GitHub Actions runners')
->assertSee('Only GitHub Apps that belong to an organization can run workflow jobs.');
});
it('survives the confirmation modal refresh after deleting an App', function () {
$unregistered = GithubApp::create(['name' => 'to-delete', 'organization' => 'acme', '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' => $unregistered->uuid])
->actingAs($this->owner)
->test(Change::class)
->call('delete')
->assertRedirect(route('source.all'))
->call('$refresh')
->assertOk();
expect(GithubApp::find($unregistered->id))->toBeNull();
});
it('blocks deleting an App while servers have enabled runners', function () {
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
expect(fn () => $this->githubApp->delete())->toThrow(Exception::class, 'GitHub Actions runners');
expect(GithubApp::find($this->githubApp->id))->not->toBeNull()
->and(GithubRunnerConfig::count())->toBe(1);
});
it('blocks deleting an App while disabled runners still run jobs', function () {
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running]);
expect(fn () => $this->githubApp->delete())->toThrow(Exception::class, 'GitHub Actions runners');
expect(GithubApp::find($this->githubApp->id))->not->toBeNull();
});
it('deletes an App after its runners are disabled and finished', function () {
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Completed]);
$this->githubApp->delete();
expect(GithubApp::find($this->githubApp->id))->toBeNull()
->and(GithubRunnerConfig::count())->toBe(0);
});
});
describe('new GitHub App purpose', function () {
it('requires an organization when the App is for GitHub Actions runners', function () {
Livewire::actingAs($this->owner)
->test(Create::class)
->set('use_for_github_runners', true)
->set('organization', '')
->call('createGitHubApp')
->assertHasErrors(['organization' => 'required']);
expect(GithubApp::where('team_id', $this->team->id)->count())->toBe(1);
});
it('marks the organization as required when GitHub Actions runners are selected', function () {
Livewire::actingAs($this->owner)
->test(Create::class)
->assertSee('Personal account when empty')
->set('use_for_github_runners', true)
->assertSee('GitHub organization, for example coollabsio')
->assertSeeHtml('required');
});
it('passes the selected purposes to the registration page', function () {
Livewire::actingAs($this->owner)
->test(Create::class)
->set('name', 'runner-purpose-app')
->set('organization', 'acme')
->set('use_for_pull_request_previews', false)
->set('use_for_github_runners', true)
->call('createGitHubApp')
->assertHasNoErrors()
->assertRedirectContains('previews=0&runners=1');
$githubApp = GithubApp::where('name', 'runner-purpose-app')->sole();
expect($githubApp->organization)->toBe('acme');
});
it('prefills the registration options from the selected purposes', function (array $query, bool $previews, bool $runners, ?string $organization) {
$unregistered = GithubApp::create(['name' => 'prefill', 'organization' => $organization, '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' => $unregistered->uuid, ...$query])
->actingAs($this->owner)
->test(Change::class)
->assertSet('preview_deployment_permissions', $previews)
->assertSet('github_runners', $runners);
})->with([
'defaults' => [[], true, false, 'acme'],
'runners only' => [['previews' => '0', 'runners' => '1'], false, true, 'acme'],
'runners without organization' => [['runners' => '1'], true, false, null],
]);
});