@if (data_get($github_app, 'installation_id'))
Save
+
Test Connection
@endif
@can('delete', $github_app)
@if ($applications->count() > 0)
diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php
new file mode 100644
index 000000000..b743fa0d7
--- /dev/null
+++ b/resources/views/livewire/source/gitlab/change.blade.php
@@ -0,0 +1,271 @@
+
+ @if ($isConnected)
+
+ @else
+
+
GitLab App
+
+ @can('delete', $gitlab_app)
+
+ @endcan
+
+
+
Connect your GitLab instance to deploy private repositories.
+
+
+
+
You must complete this step before you can use this source!
+
+
+
+
Step 1: Create an OAuth Application on GitLab
+
+
Go to your GitLab instance and create a new OAuth Application:
+
+ {{ rtrim($htmlUrl, '/') }}/-/profile/applications
+
+
+
+ - Set Redirect URI to:
+
{{ $redirectUri }}
+
+ - Enable scopes:
api, read_user, read_repository
+ - Uncheck Confidential if you run into issues
+
+
+
+
+
+ @if ($clientId)
+
Step 3: Authorize with GitLab
+
Click the button below to authorize Coolify with your GitLab instance. The redirect URI must match the Callback URL configured in GitLab.
+
+
+ Connect to GitLab
+
+
+ @endif
+
+ @endif
+
diff --git a/resources/views/livewire/source/gitlab/create.blade.php b/resources/views/livewire/source/gitlab/create.blade.php
new file mode 100644
index 000000000..4b053edb7
--- /dev/null
+++ b/resources/views/livewire/source/gitlab/create.blade.php
@@ -0,0 +1,70 @@
+@can('createAnyResource')
+
+@else
+
+ You don't have permission to create new GitLab Apps. Please contact your team administrator for access.
+
+@endcan
diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php
index c6566d2f9..c74e46093 100644
--- a/resources/views/source/all.blade.php
+++ b/resources/views/source/all.blade.php
@@ -5,9 +5,12 @@
Sources
@can('createAnyResource')
-
+
+
+
+
@endcan
Git sources for your applications.
@@ -17,15 +20,31 @@
- {{-- --}}
-
{{ $source->name }}
- @if (is_null($source->app_id))
-
Configuration is not finished.
+
+
+ {{ $source->name }}
+
+ @if ($source->isConnected())
+
Connected
@else
- @if ($source->organization)
-
Organization: {{ $source->organization }}
- @endif
+
Setup required
+ @endif
+
+
+ @elseif ($source->getMorphClass() === 'App\Models\GitlabApp')
+
+
+
+
+ {{ $source->name }}
+
+ @if ($source->isConnected())
+
Connected
+ @else
+
Setup required
@endif
diff --git a/routes/api.php b/routes/api.php
index b4c174a51..b9d2f6c6f 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -7,9 +7,9 @@ use App\Http\Controllers\Api\DeployController;
use App\Http\Controllers\Api\DestinationsController;
use App\Http\Controllers\Api\DigitalOceanController;
use App\Http\Controllers\Api\GithubController;
+use App\Http\Controllers\Api\GitlabController;
use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\Internal\FluxResourceStatusController;
-use App\Support\V5\V5Feature;
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
@@ -25,6 +25,7 @@ use App\Http\Controllers\Api\TeamController;
use App\Http\Controllers\Api\VolumeBackupsController;
use App\Http\Controllers\Api\VultrController;
use App\Http\Middleware\ApiAllowed;
+use App\Support\V5\V5Feature;
use Illuminate\Support\Facades\Route;
Route::get('/health', [OtherController::class, 'healthcheck']);
@@ -185,6 +186,11 @@ Route::group([
Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']);
Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']);
+ Route::get('/gitlab-apps', [GitlabController::class, 'list_gitlab_apps'])->middleware(['api.ability:read']);
+ Route::post('/gitlab-apps', [GitlabController::class, 'create_gitlab_app'])->middleware(['api.ability:write']);
+ Route::patch('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'update_gitlab_app'])->middleware(['api.ability:write']);
+ Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:write']);
+
Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']);
Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']);
Route::post('/databases/mysql', [DatabasesController::class, 'create_database_mysql'])->middleware(['api.ability:write']);
diff --git a/routes/web.php b/routes/web.php
index c26b3eb15..6a212725f 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -83,6 +83,7 @@ use App\Livewire\SharedVariables\Server\Index as ServerSharedVariablesIndex;
use App\Livewire\SharedVariables\Server\Show as ServerSharedVariablesShow;
use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex;
use App\Livewire\Source\Github\Change as GitHubChange;
+use App\Livewire\Source\Gitlab\Change as GitLabChange;
use App\Livewire\Storage\Index as StorageIndex;
use App\Livewire\Storage\Show as StorageShow;
use App\Livewire\Subscription\Index as SubscriptionIndex;
@@ -353,6 +354,7 @@ Route::middleware(['auth'])->group(function () {
Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show');
Route::get('/source/github/{github_app_uuid}/permissions', GitHubChange::class)->name('source.github.permissions');
Route::get('/source/github/{github_app_uuid}/resources', GitHubChange::class)->name('source.github.resources');
+ Route::get('/source/gitlab/{gitlab_app_uuid}', GitLabChange::class)->name('source.gitlab.show');
});
Route::middleware(['auth'])->group(function () {
diff --git a/routes/webhooks.php b/routes/webhooks.php
index 804fd7bcb..27796b807 100644
--- a/routes/webhooks.php
+++ b/routes/webhooks.php
@@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Route;
Route::middleware(['web', 'auth', 'throttle:30,1'])->group(function () {
Route::get('/source/github/redirect', [Github::class, 'redirect']);
Route::get('/source/github/install', [Github::class, 'install']);
+ Route::get('/source/gitlab/redirect', [Gitlab::class, 'redirect']);
});
Route::post('/source/github/events', [Github::class, 'normal']);
Route::post('/source/github/events/manual', [Github::class, 'manual']);
+Route::post('/source/gitlab/events', [Gitlab::class, 'normal']);
Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']);
Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']);
diff --git a/tests/Feature/Api/GitlabAppsApiTest.php b/tests/Feature/Api/GitlabAppsApiTest.php
new file mode 100644
index 000000000..65332c0dd
--- /dev/null
+++ b/tests/Feature/Api/GitlabAppsApiTest.php
@@ -0,0 +1,182 @@
+set('app.maintenance.driver', 'file');
+ config()->set('cache.default', 'array');
+
+ InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+});
+
+describe('GET /api/v1/gitlab-apps', function () {
+ test('returns 401 when not authenticated', function () {
+ $this->getJson('/api/v1/gitlab-apps')->assertStatus(401);
+ });
+
+ test('returns empty array when no gitlab apps exist', function () {
+ $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson('/api/v1/gitlab-apps')
+ ->assertSuccessful()
+ ->assertJson([]);
+ });
+
+ test('returns team gitlab apps without secrets for read tokens', function () {
+ GitlabApp::create([
+ 'name' => 'Team GitLab',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'client_id' => 'client-id',
+ 'client_secret' => 'secret-should-be-hidden',
+ 'webhook_token' => 'webhook-should-be-hidden',
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$readToken,
+ ])->getJson('/api/v1/gitlab-apps');
+
+ $response->assertSuccessful()
+ ->assertJsonCount(1)
+ ->assertJsonFragment(['name' => 'Team GitLab']);
+
+ expect($response->json('0'))->not->toHaveKey('client_secret')
+ ->and($response->json('0'))->not->toHaveKey('webhook_token');
+ });
+});
+
+describe('POST /api/v1/gitlab-apps', function () {
+ test('creates a gitlab app with derived api url and generated webhook token', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->postJson('/api/v1/gitlab-apps', [
+ 'name' => 'Self-hosted GitLab',
+ 'html_url' => 'https://gitlab.com/',
+ 'group_name' => 'mygroup',
+ ]);
+
+ $response->assertCreated()
+ ->assertJsonFragment([
+ 'name' => 'Self-hosted GitLab',
+ 'html_url' => 'https://gitlab.com',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'group_name' => 'mygroup',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ ]);
+
+ $app = GitlabApp::where('name', 'Self-hosted GitLab')->first();
+ expect($app)->not->toBeNull()
+ ->and($app->team_id)->toBe($this->team->id)
+ ->and($app->webhook_token)->not->toBeEmpty()
+ ->and(strlen((string) $app->webhook_token))->toBe(32);
+ });
+
+ test('creates a fully configured gitlab oauth source', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->postJson('/api/v1/gitlab-apps', [
+ 'name' => 'Configured GitLab',
+ 'html_url' => 'https://gitlab.com',
+ 'client_id' => 'oauth-app-id',
+ 'client_secret' => 'oauth-app-secret',
+ 'webhook_token' => 'custom-webhook-token',
+ 'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect',
+ ]);
+
+ $response->assertCreated()
+ ->assertJsonFragment([
+ 'name' => 'Configured GitLab',
+ 'client_id' => 'oauth-app-id',
+ 'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect',
+ ]);
+
+ $app = GitlabApp::where('name', 'Configured GitLab')->first();
+ $app->makeVisible(['client_secret', 'webhook_token']);
+ expect($app->client_secret)->toBe('oauth-app-secret')
+ ->and($app->webhook_token)->toBe('custom-webhook-token');
+ });
+
+ test('rejects members without create permission', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+ session(['currentTeam' => $this->team]);
+ $memberToken = $member->createToken('member-token', ['write'])->plainTextToken;
+
+ $this->withHeaders([
+ 'Authorization' => 'Bearer '.$memberToken,
+ ])->postJson('/api/v1/gitlab-apps', [
+ 'name' => 'Forbidden GitLab',
+ 'html_url' => 'https://gitlab.com',
+ ])->assertForbidden();
+ });
+});
+
+describe('PATCH /api/v1/gitlab-apps/{id}', function () {
+ test('updates gitlab app credentials', function () {
+ $app = GitlabApp::create([
+ 'name' => 'Existing',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->patchJson("/api/v1/gitlab-apps/{$app->id}", [
+ 'client_id' => 'new-client-id',
+ 'group_name' => 'ops',
+ ])->assertSuccessful()
+ ->assertJsonPath('message', 'GitLab app updated successfully')
+ ->assertJsonPath('data.client_id', 'new-client-id')
+ ->assertJsonPath('data.group_name', 'ops');
+ });
+});
+
+describe('DELETE /api/v1/gitlab-apps/{id}', function () {
+ test('deletes unused gitlab app', function () {
+ $app = GitlabApp::create([
+ 'name' => 'Delete me',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->deleteJson("/api/v1/gitlab-apps/{$app->id}")
+ ->assertSuccessful()
+ ->assertJsonPath('message', 'GitLab app deleted successfully');
+
+ expect(GitlabApp::find($app->id))->toBeNull();
+ });
+});
diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php
index 40a4e7314..6eb97e420 100644
--- a/tests/Feature/Application/GithubSourceChangeTest.php
+++ b/tests/Feature/Application/GithubSourceChangeTest.php
@@ -653,4 +653,161 @@ describe('GitHub Source Change Component', function () {
Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
});
+
+ test('isConnected is true only when app and installation are present', function () {
+ $incomplete = GithubApp::create([
+ 'name' => 'Incomplete App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'app_id' => 12345,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $connected = GithubApp::create([
+ 'name' => 'Connected App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'app_id' => 12345,
+ 'installation_id' => 67890,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $public = new GithubApp([
+ 'is_public' => true,
+ ]);
+
+ expect($incomplete->isConnected())->toBeFalse()
+ ->and($connected->isConnected())->toBeTrue()
+ ->and($public->isConnected())->toBeTrue();
+ });
+
+ test('shows connected badge and test connection for installed github apps', function () {
+ $privateKey = PrivateKey::create([
+ 'name' => 'Test Key',
+ 'private_key' => validPrivateKey(),
+ 'team_id' => $this->team->id,
+ ]);
+
+ $githubApp = GithubApp::create([
+ 'name' => 'Connected GitHub App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.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,
+ ]);
+
+ Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
+ ->test(Change::class)
+ ->assertSuccessful()
+ ->assertSet('isConnected', true)
+ ->assertSee('Connected')
+ ->assertSee('Test Connection');
+ });
+
+ test('testConnection succeeds when github app credentials are valid', function () {
+ $privateKey = PrivateKey::create([
+ 'name' => 'Test Key',
+ 'private_key' => validPrivateKey(),
+ 'team_id' => $this->team->id,
+ ]);
+
+ $githubApp = GithubApp::create([
+ 'name' => 'Connected GitHub App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.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.com/zen' => Http::response('Keep it logically awesome.', 200, [
+ 'date' => now()->toRfc7231String(),
+ ]),
+ 'https://api.github.com/app' => Http::response([
+ 'name' => 'Coolify GitHub App',
+ 'slug' => 'coolify-github-app',
+ ]),
+ 'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
+ 'token' => 'ghs_test_installation_token',
+ ]),
+ ]);
+
+ Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
+ ->test(Change::class)
+ ->assertSuccessful()
+ ->call('testConnection')
+ ->assertDispatched('success', 'Connection successful! Authenticated as GitHub App: Coolify GitHub App');
+ });
+
+ test('testConnection fails when github app is not fully installed', function () {
+ $githubApp = GithubApp::create([
+ 'name' => 'Incomplete GitHub App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'app_id' => 12345,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ ]);
+
+ Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
+ ->test(Change::class)
+ ->assertSuccessful()
+ ->assertSet('isConnected', false)
+ ->call('testConnection')
+ ->assertDispatched('error', 'GitHub App is not fully set up. Please complete installation first.');
+ });
+
+ test('sources list shows Connected for finished github apps', function () {
+ GithubApp::create([
+ 'name' => 'Finished GitHub App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'app_id' => 12345,
+ 'installation_id' => 67890,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ GithubApp::create([
+ 'name' => 'Incomplete 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,
+ 'is_public' => false,
+ ]);
+
+ $this->get(route('source.all'))
+ ->assertSuccessful()
+ ->assertSee('Finished GitHub App')
+ ->assertSee('Connected')
+ ->assertSee('Incomplete GitHub App')
+ ->assertSee('Setup required');
+ });
});
diff --git a/tests/Feature/ApplicationSourceTypeFilterTest.php b/tests/Feature/ApplicationSourceTypeFilterTest.php
new file mode 100644
index 000000000..a4ff86d4f
--- /dev/null
+++ b/tests/Feature/ApplicationSourceTypeFilterTest.php
@@ -0,0 +1,73 @@
+id = 0;
+ $settings->save();
+ }
+
+ $this->user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+test('a GitLab source is not hidden by a GitHub source sharing the same numeric id', function () {
+ $githubApp = GithubApp::create([
+ 'name' => 'gh',
+ 'team_id' => $this->team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'is_public' => false,
+ 'app_id' => 123,
+ ]);
+
+ $gitlabApp = GitlabApp::create([
+ 'name' => 'gl',
+ 'team_id' => $this->team->id,
+ 'api_url' => 'https://gitlab.example.test/api/v4',
+ 'html_url' => 'https://gitlab.example.test',
+ 'is_public' => false,
+ 'access_token' => 'token',
+ 'refresh_token' => 'refresh',
+ 'expires_at' => time() + 3600,
+ ]);
+
+ // The two source tables auto-increment independently, so the first row in each shares id 1.
+ expect($gitlabApp->id)->toBe($githubApp->id);
+
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'private_key_id' => null,
+ 'source_id' => $githubApp->id,
+ 'source_type' => GithubApp::class,
+ ]);
+
+ $component = Livewire::test(Source::class, ['application' => $application]);
+ $sources = $component->get('sources');
+
+ expect($sources->contains(fn ($s) => $s instanceof GitlabApp && $s->id === $gitlabApp->id))->toBeTrue();
+ expect($sources->contains(fn ($s) => $s instanceof GithubApp && $s->id === $githubApp->id))->toBeFalse();
+
+ // The GitLab source renders as selectable, not flagged as the current GitHub source despite the shared id.
+ $component->assertSee('gl')->assertDontSee('(current)');
+});
diff --git a/tests/Feature/GitlabAppAuthorizationTest.php b/tests/Feature/GitlabAppAuthorizationTest.php
new file mode 100644
index 000000000..4ff591120
--- /dev/null
+++ b/tests/Feature/GitlabAppAuthorizationTest.php
@@ -0,0 +1,147 @@
+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']);
+
+ InstanceSettings::forceCreate([
+ 'id' => 0,
+ 'fqdn' => null,
+ 'public_ipv4' => null,
+ 'public_ipv6' => null,
+ ]);
+
+ $this->gitlabApp = GitlabApp::create([
+ 'name' => 'Self-hosted GitLab',
+ 'api_url' => 'https://gitlab.example.com/api/v4',
+ 'html_url' => 'https://gitlab.example.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'webhook_token' => 'secret-webhook-token',
+ 'access_token' => 'access-token',
+ 'refresh_token' => 'refresh-token',
+ 'expires_at' => time() + 3600,
+ 'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitlab/redirect',
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+});
+
+describe('GitLab App authorization', function () {
+ test('unrelated users cannot inspect system-wide source secrets in the component payload', function () {
+ $otherTeam = Team::factory()->create();
+ $systemWideSource = GitlabApp::create([
+ 'name' => 'Shared GitLab',
+ 'api_url' => 'https://gitlab.example.com/api/v4',
+ 'html_url' => 'https://gitlab.example.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'client_id' => 'shared-client-id',
+ 'client_secret' => 'shared-client-secret',
+ 'webhook_token' => 'shared-webhook-token',
+ 'access_token' => 'shared-access-token',
+ 'refresh_token' => 'shared-refresh-token',
+ 'expires_at' => time() + 3600,
+ 'team_id' => $otherTeam->id,
+ 'is_system_wide' => true,
+ 'is_public' => false,
+ ]);
+
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::withQueryParams(['gitlab_app_uuid' => $systemWideSource->uuid])
+ ->test(Change::class)
+ ->assertSet('clientSecretInput', null)
+ ->assertSet('webhookToken', null);
+
+ expect($component->html())
+ ->not->toContain('shared-client-secret')
+ ->not->toContain('shared-webhook-token');
+ });
+
+ test('team member cannot update a gitlab app via instantSave', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->set('isSystemWide', true)
+ ->call('instantSave')
+ ->assertDispatched('error');
+
+ expect($this->gitlabApp->refresh()->is_system_wide)->toBeFalse();
+ });
+
+ test('team owner can update a gitlab app via instantSave', function () {
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->set('isSystemWide', true)
+ ->call('instantSave')
+ ->assertDispatched('success');
+
+ expect($this->gitlabApp->refresh()->is_system_wide)->toBeTrue();
+ });
+
+ test('instantSave rejects unsafe GitLab URLs', function (string $url) {
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->set('htmlUrl', $url)
+ ->set('apiUrl', $url.'/api/v4')
+ ->set('isSystemWide', true)
+ ->call('instantSave')
+ ->assertDispatched('success');
+
+ $this->gitlabApp->refresh();
+
+ expect($this->gitlabApp->html_url)->toBe('https://gitlab.example.com')
+ ->and($this->gitlabApp->api_url)->toBe('https://gitlab.example.com/api/v4')
+ ->and($this->gitlabApp->is_system_wide)->toBeTrue();
+ })->with([
+ 'private address' => 'http://10.0.0.1',
+ 'loopback address' => 'http://127.0.0.1',
+ 'metadata service address' => 'http://169.254.169.254',
+ ]);
+
+ test('team member cannot create an application from a private gitlab repository', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $applicationsBefore = Application::count();
+
+ // Avoid setting selected_project_id — its updated* hook loads branches and is unrelated to this auth check.
+ Livewire::test(GitlabPrivateRepository::class, ['type' => 'private-gitlab-app'])
+ ->set('selected_repository_path', 'group/repo')
+ ->set('selected_branch_name', 'main')
+ ->set('selected_gitlab_app_id', $this->gitlabApp->id)
+ ->set('gitlab_app_id', $this->gitlabApp->id)
+ ->call('submit')
+ ->assertDispatched('error');
+
+ expect(Application::count())->toBe($applicationsBefore);
+ });
+});
diff --git a/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php b/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php
new file mode 100644
index 000000000..0a44e9c5a
--- /dev/null
+++ b/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php
@@ -0,0 +1,67 @@
+team = Team::create([
+ 'name' => 'Webhook Token Team',
+ 'personal_team' => false,
+ ]);
+});
+
+it('encrypts webhook tokens at rest', function () {
+ $app = GitlabApp::create([
+ 'name' => 'Encrypted webhook',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'webhook_token' => 'plain-webhook-secret',
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $raw = DB::table('gitlab_apps')->where('id', $app->id)->value('webhook_token');
+ expect($raw)->not->toBe('plain-webhook-secret')
+ ->and(Crypt::decryptString($raw))->toBe('plain-webhook-secret')
+ ->and($app->fresh()->webhook_token)->toBe('plain-webhook-secret');
+});
+
+it('finds an app by webhook token for both encrypted and legacy plaintext values', function () {
+ $encrypted = GitlabApp::create([
+ 'name' => 'Encrypted',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'webhook_token' => 'encrypted-secret',
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $legacy = GitlabApp::create([
+ 'name' => 'Legacy',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+ DB::table('gitlab_apps')->where('id', $legacy->id)->update([
+ 'webhook_token' => 'legacy-plain-secret',
+ ]);
+
+ expect(GitlabApp::findByWebhookToken('encrypted-secret')?->id)->toBe($encrypted->id)
+ ->and(GitlabApp::findByWebhookToken('legacy-plain-secret')?->id)->toBe($legacy->id)
+ ->and(GitlabApp::findByWebhookToken('missing'))->toBeNull();
+});
diff --git a/tests/Feature/GitlabOAuthCallbackStateTest.php b/tests/Feature/GitlabOAuthCallbackStateTest.php
new file mode 100644
index 000000000..c9d693dfe
--- /dev/null
+++ b/tests/Feature/GitlabOAuthCallbackStateTest.php
@@ -0,0 +1,129 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->gitlabApp = GitlabApp::create([
+ 'name' => 'Self-hosted GitLab',
+ 'api_url' => 'https://gitlab.example.com/api/v4',
+ 'html_url' => 'https://gitlab.example.com',
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitlab/redirect',
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+});
+
+describe('GitLab OAuth callback state validation', function () {
+ test('rejects a callback whose state is the source UUID (the old attack vector)', function () {
+ Http::fake();
+
+ $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$this->gitlabApp->uuid);
+
+ $response->assertRedirect(route('source.all'));
+ Http::assertNothingSent();
+ expect($this->gitlabApp->refresh()->access_token)->toBeNull();
+ });
+
+ test('rejects a callback with an unknown / expired state and never exchanges the code', function () {
+ Http::fake();
+
+ $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=not-a-real-state');
+
+ $response->assertRedirect(route('source.all'));
+ Http::assertNothingSent();
+ expect($this->gitlabApp->refresh()->access_token)->toBeNull();
+ });
+
+ test('rejects a state issued for a different team', function () {
+ Http::fake();
+ $otherTeam = Team::factory()->create();
+ $state = 'state-for-other-team';
+ Cache::put(GitlabSource::oauthStateCacheKey($state), [
+ 'gitlab_app_id' => $this->gitlabApp->id,
+ 'team_id' => $otherTeam->id,
+ ], now()->addMinutes(60));
+
+ $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$state);
+
+ $response->assertRedirect(route('source.all'));
+ Http::assertNothingSent();
+ expect($this->gitlabApp->refresh()->access_token)->toBeNull();
+ });
+
+ test('accepts a valid one-time state, exchanges the code, and consumes the state', function () {
+ Http::fake([
+ '*/oauth/token' => Http::response([
+ 'access_token' => 'new-access-token',
+ 'refresh_token' => 'new-refresh-token',
+ 'expires_in' => 7200,
+ ]),
+ ]);
+
+ $state = 'a-valid-server-issued-state';
+ $key = GitlabSource::oauthStateCacheKey($state);
+ Cache::put($key, [
+ 'gitlab_app_id' => $this->gitlabApp->id,
+ 'team_id' => $this->team->id,
+ ], now()->addMinutes(60));
+
+ $response = $this->get('/webhooks/source/gitlab/redirect?code=valid-code&state='.$state);
+
+ $response->assertRedirect(route('source.gitlab.show', ['gitlab_app_uuid' => $this->gitlabApp->uuid]));
+
+ $fresh = $this->gitlabApp->refresh();
+ $fresh->makeVisible(['access_token', 'refresh_token']);
+ expect($fresh->access_token)->toBe('new-access-token');
+ expect($fresh->refresh_token)->toBe('new-refresh-token');
+
+ // State must be single-use.
+ expect(Cache::get($key))->toBeNull();
+ });
+
+ test('requires authentication', function () {
+ auth()->logout();
+ session()->forget('currentTeam');
+
+ $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=any');
+
+ $response->assertRedirect(route('login'));
+ });
+
+ test('rejects a callback from a team member who cannot administer the source', function () {
+ Http::fake();
+
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+ $this->actingAs($member);
+ session(['currentTeam' => $this->team]);
+
+ $state = 'member-state';
+ Cache::put(GitlabSource::oauthStateCacheKey($state), [
+ 'gitlab_app_id' => $this->gitlabApp->id,
+ 'team_id' => $this->team->id,
+ ], now()->addMinutes(60));
+
+ $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$state);
+
+ $response->assertRedirect(route('source.all'));
+ Http::assertNothingSent();
+ expect($this->gitlabApp->refresh()->access_token)->toBeNull();
+ });
+});
diff --git a/tests/Feature/GitlabRepositoryListingTest.php b/tests/Feature/GitlabRepositoryListingTest.php
new file mode 100644
index 000000000..f42263b9d
--- /dev/null
+++ b/tests/Feature/GitlabRepositoryListingTest.php
@@ -0,0 +1,52 @@
+ Http::response(['message' => '401 Unauthorized'], 401)]);
+
+ $source = new GitlabApp([
+ 'api_url' => 'https://gitlab.example.test/api/v4',
+ 'access_token' => str_repeat('t', 20), // ggignore
+ 'refresh_token' => str_repeat('r', 20), // ggignore
+ 'expires_at' => time() + 3600,
+ ]);
+
+ $result = loadGitlabRepositories($source);
+
+ expect($result)->toHaveKeys(['total_count', 'has_more', 'repositories']);
+ expect($result['has_more'])->toBeFalse();
+ expect($result['repositories'])->toBe([]);
+});
+
+it('limits GitLab repositories to the exact group and its descendants', function () {
+ Http::fake(['*' => Http::response([
+ ['id' => 1, 'name' => 'a', 'path_with_namespace' => 'team/a', 'namespace' => ['full_path' => 'team', 'kind' => 'group']],
+ ['id' => 2, 'name' => 'b', 'path_with_namespace' => 'team/sub/b', 'namespace' => ['full_path' => 'team/sub', 'kind' => 'group']],
+ ['id' => 3, 'name' => 'c', 'path_with_namespace' => 'team-secret/c', 'namespace' => ['full_path' => 'team-secret', 'kind' => 'group']],
+ ], 200)]);
+
+ $source = new GitlabApp([
+ 'api_url' => 'https://gitlab.example.test/api/v4',
+ 'access_token' => str_repeat('t', 20), // ggignore
+ 'refresh_token' => str_repeat('r', 20), // ggignore
+ 'expires_at' => time() + 3600,
+ 'group_name' => 'team',
+ ]);
+
+ $paths = collect(loadGitlabRepositories($source)['repositories'])->pluck('path_with_namespace')->all();
+
+ expect($paths)->toContain('team/a', 'team/sub/b');
+ expect($paths)->not->toContain('team-secret/c');
+});
diff --git a/tests/Feature/GitlabSourceChangeViewTest.php b/tests/Feature/GitlabSourceChangeViewTest.php
new file mode 100644
index 000000000..78fb3bf50
--- /dev/null
+++ b/tests/Feature/GitlabSourceChangeViewTest.php
@@ -0,0 +1,89 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ InstanceSettings::forceCreate([
+ 'id' => 0,
+ 'fqdn' => null,
+ 'public_ipv4' => null,
+ 'public_ipv6' => null,
+ ]);
+
+ $this->gitlabApp = GitlabApp::create([
+ 'name' => 'Self-hosted GitLab',
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ 'custom_user' => 'git',
+ 'custom_port' => 22,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+});
+
+describe('GitLab source setup view', function () {
+ test('shows red incomplete-setup alert and keeps advanced fields collapsed', function () {
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->assertSee('You must complete this step before you can use this source!')
+ ->assertSeeHtml('alert-error')
+ ->assertSee('Advanced / Self-hosted')
+ ->assertSee('Application ID')
+ ->assertSee('Application Secret')
+ ->assertSee('Save')
+ ->assertDontSee('alert-warning');
+ });
+
+ test('derives api url when gitlab url changes', function () {
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->set('htmlUrl', 'https://gitlab.example.com')
+ ->assertSet('apiUrl', 'https://gitlab.example.com/api/v4');
+ });
+
+ test('saves and reloads the application secret after refresh', function () {
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->set('clientId', 'gitlab-app-id')
+ ->set('clientSecretInput', 'super-secret-value')
+ ->call('submit')
+ ->assertDispatched('success');
+
+ $this->gitlabApp->refresh()->makeVisible(['client_secret']);
+ expect($this->gitlabApp->client_secret)->toBe('super-secret-value');
+
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->assertSet('clientId', 'gitlab-app-id')
+ ->assertSet('clientSecretInput', 'super-secret-value');
+ });
+
+ test('supports github-style custom public endpoint for oauth redirect uri', function () {
+ Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
+ ->test(Change::class)
+ ->assertSee('Use custom webhook endpoint')
+ ->assertSee('Selected endpoint')
+ ->set('use_custom_webhook_endpoint', true)
+ ->set('custom_webhook_endpoint', 'http://100.75.155.70:8000')
+ ->assertSet('redirectUri', 'http://100.75.155.70:8000/webhooks/source/gitlab/redirect');
+
+ expect($this->gitlabApp->refresh()->redirect_uri)
+ ->toBe('http://100.75.155.70:8000/webhooks/source/gitlab/redirect');
+ });
+});
diff --git a/tests/Feature/GitlabSourceCreateModalTest.php b/tests/Feature/GitlabSourceCreateModalTest.php
new file mode 100644
index 000000000..073fc1b74
--- /dev/null
+++ b/tests/Feature/GitlabSourceCreateModalTest.php
@@ -0,0 +1,50 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+describe('GitLab source create modal', function () {
+ test('matches github create modal structure', function () {
+ Livewire::test(Create::class)
+ ->assertSee('This is required if you would like to get full integration')
+ ->assertSee('Self-hosted GitLab')
+ ->assertSee('Continue')
+ ->assertDontSee('>Save', false)
+ ->assertDontSeeHtml('
New GitLab App
');
+ });
+
+ test('creates a gitlab app with defaults for gitlab.com', function () {
+ Livewire::test(Create::class)
+ ->set('name', 'my-gitlab')
+ ->call('createGitLabApp')
+ ->assertRedirect();
+
+ $app = GitlabApp::where('name', 'my-gitlab')->first();
+ expect($app)->not->toBeNull()
+ ->and($app->html_url)->toBe('https://gitlab.com')
+ ->and($app->api_url)->toBe('https://gitlab.com/api/v4')
+ ->and($app->custom_user)->toBe('git')
+ ->and($app->custom_port)->toBe(22);
+ });
+
+ test('derives api url when html url changes', function () {
+ Livewire::test(Create::class)
+ ->set('html_url', 'https://gitlab.example.com')
+ ->assertSet('api_url', 'https://gitlab.example.com/api/v4');
+ });
+});
diff --git a/tests/Feature/GitlabSystemWideSourceTest.php b/tests/Feature/GitlabSystemWideSourceTest.php
new file mode 100644
index 000000000..05aab8762
--- /dev/null
+++ b/tests/Feature/GitlabSystemWideSourceTest.php
@@ -0,0 +1,51 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+test('ownedByCurrentTeam resolves a system-wide GitLab source owned by another team', function () {
+ $otherTeam = Team::factory()->create();
+
+ $systemWide = GitlabApp::create([
+ 'name' => 'Shared GitLab',
+ 'api_url' => 'https://gitlab.example.com/api/v4',
+ 'html_url' => 'https://gitlab.example.com',
+ 'team_id' => $otherTeam->id,
+ 'is_system_wide' => true,
+ 'is_public' => false,
+ ]);
+
+ // Mirrors Source::changeSource() resolution; before the fix this returned null for system-wide sources (404).
+ $resolved = GitlabApp::ownedByCurrentTeam()->find($systemWide->id);
+
+ expect($resolved)->not->toBeNull();
+ expect($resolved->id)->toBe($systemWide->id);
+});
+
+test('ownedByCurrentTeam still excludes other teams private (non system-wide) sources', function () {
+ $otherTeam = Team::factory()->create();
+
+ $foreign = GitlabApp::create([
+ 'name' => 'Foreign GitLab',
+ 'api_url' => 'https://gitlab.example.com/api/v4',
+ 'html_url' => 'https://gitlab.example.com',
+ 'team_id' => $otherTeam->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ expect(GitlabApp::ownedByCurrentTeam()->find($foreign->id))->toBeNull();
+});
diff --git a/tests/Unit/GitlabAppSubmoduleCredentialsTest.php b/tests/Unit/GitlabAppSubmoduleCredentialsTest.php
new file mode 100644
index 000000000..bf713070f
--- /dev/null
+++ b/tests/Unit/GitlabAppSubmoduleCredentialsTest.php
@@ -0,0 +1,56 @@
+forceFill([
+ 'uuid' => 'test-app-uuid',
+ 'git_repository' => 'group/private-app',
+ 'git_branch' => 'main',
+ 'git_commit_sha' => 'HEAD',
+ ]);
+
+ $settings = new ApplicationSetting;
+ $settings->is_git_shallow_clone_enabled = false;
+ $settings->is_git_submodules_enabled = true;
+ $settings->is_git_lfs_enabled = false;
+ $application->setRelation('settings', $settings);
+
+ $source = new GitlabApp;
+ $source->forceFill([
+ 'html_url' => 'https://gitlab.example.test',
+ 'api_url' => 'https://gitlab.example.test/api/v4',
+ 'is_public' => false,
+ ]);
+ // A non-expired token short-circuits refreshGitlabToken(), so no HTTP call is made.
+ $source->access_token = 'gl-token/with+sym';
+ $source->refresh_token = 'gl-refresh-token';
+ $source->expires_at = time() + 3600;
+ $application->setRelation('source', $source);
+
+ $result = $application->generateGitImportCommands(
+ deployment_uuid: 'test-deployment',
+ exec_in_docker: false,
+ );
+
+ $expectedConfig = "git -c 'url.https://oauth2:gl-token%2Fwith%2Bsym@gitlab.example.test/.insteadOf=https://gitlab.example.test/' -c http.version=HTTP/1.1";
+
+ expect($result['commands'])
+ ->not->toContain('git config --global')
+ ->toContain("{$expectedConfig} clone --recurse-submodules -b 'main'")
+ ->toContain("{$expectedConfig} submodule sync")
+ ->toContain("{$expectedConfig} submodule update --init --recursive");
+});
diff --git a/tests/Unit/GitlabHelperTest.php b/tests/Unit/GitlabHelperTest.php
new file mode 100644
index 000000000..bac21b205
--- /dev/null
+++ b/tests/Unit/GitlabHelperTest.php
@@ -0,0 +1,52 @@
+ 'https://gitlab.example.com']);
+ expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
+});
+
+it('returns api base url unchanged when /api/v4 already present', function () {
+ $app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4']);
+ expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
+});
+
+it('strips trailing slash from api url base', function () {
+ $app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4/']);
+ expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
+});
+
+it('reports connected when tokens are present', function () {
+ $app = new GitlabApp([
+ 'access_token' => str_repeat('t', 20), // ggignore
+ 'refresh_token' => str_repeat('r', 20), // ggignore
+ ]);
+ expect($app->isConnected())->toBeTrue();
+});
+
+it('reports not connected when tokens are missing', function () {
+ $app = new GitlabApp([
+ 'access_token' => null,
+ 'refresh_token' => null,
+ ]);
+ expect($app->isConnected())->toBeFalse();
+});
+
+it('reports not connected when only access token is present', function () {
+ $app = new GitlabApp([
+ 'access_token' => str_repeat('t', 20), // ggignore
+ 'refresh_token' => null,
+ ]);
+ expect($app->isConnected())->toBeFalse();
+});
diff --git a/tests/Unit/GitlabOAuthRedactionTest.php b/tests/Unit/GitlabOAuthRedactionTest.php
new file mode 100644
index 000000000..e5a4d6569
--- /dev/null
+++ b/tests/Unit/GitlabOAuthRedactionTest.php
@@ -0,0 +1,27 @@
+not->toContain($fakeOAuthToken);
+ expect($result)->toContain('oauth2:');
+});
+
+it('redacts x-access-token from logs', function () {
+ $fakeToken = str_repeat('x', 40); // ggignore
+ $text = "git clone https://x-access-token:{$fakeToken}@github.com/org/repo.git /app";
+ $result = remove_iip($text);
+
+ expect($result)->not->toContain($fakeToken);
+ expect($result)->toContain('x-access-token:');
+});
+
+it('redacts gitlab personal access tokens', function () {
+ $fakeToken = 'glpat-'.str_repeat('y', 20); // ggignore
+ $text = "Authorization: Bearer {$fakeToken}";
+ $result = remove_iip($text);
+
+ expect($result)->not->toContain($fakeToken);
+});
diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php
index 129a86506..e27236a8f 100644
--- a/tests/Unit/GitlabSourceCommandsTest.php
+++ b/tests/Unit/GitlabSourceCommandsTest.php
@@ -3,9 +3,16 @@
use App\Models\Application;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Collection;
+beforeEach(function () {
+ Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
+});
+
afterEach(function () {
+ Model::encryptUsing(null);
Mockery::close();
});
@@ -54,6 +61,9 @@ it('generates ls-remote commands for GitLab source with private key', function (
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
+ $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
+ $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
+ $gitlabSource->shouldReceive('isConnected')->andReturn(false);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
@@ -83,6 +93,9 @@ it('generates ls-remote commands for GitLab source without private key', functio
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
+ $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
+ $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
+ $gitlabSource->shouldReceive('isConnected')->andReturn(false);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
@@ -112,6 +125,9 @@ it('does not return null for GitLab source type', function () {
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
+ $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
+ $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
+ $gitlabSource->shouldReceive('isConnected')->andReturn(false);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
@@ -127,3 +143,73 @@ it('does not return null for GitLab source type', function () {
expect($lsRemoteResult)->not->toBeNull();
expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
});
+
+it('preserves custom GitLab http port for connected OAuth sources', function () {
+ $deploymentUuid = 'test-deployment-uuid';
+
+ $gitlabSource = new GitlabApp([
+ 'html_url' => 'http://gitlab.example.test:8081',
+ 'access_token' => 'gitlab-access-token',
+ 'refresh_token' => 'gitlab-refresh-token',
+ 'expires_at' => time() + 3600,
+ ]);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->git_branch = 'main';
+ $application->shouldReceive('deploymentType')->andReturn('source');
+ $application->shouldReceive('customRepository')->andReturn([
+ 'repository' => 'root/qa-private-app',
+ 'port' => 22,
+ ]);
+ $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
+ $application->source = $gitlabSource;
+
+ $result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
+
+ expect($result['fullRepoUrl'])
+ ->toContain('gitlab.example.test:8081')
+ ->toBe('http://oauth2:gitlab-access-token@gitlab.example.test:8081/root/qa-private-app.git');
+ expect($result['commands'])->toContain('gitlab.example.test:8081/root/qa-private-app.git');
+});
+
+it('applies OAuth git config to GitLab merge-request fetch and submodule checkout', function () {
+ $deploymentUuid = 'test-deployment-uuid';
+
+ $gitlabSource = new GitlabApp([
+ 'html_url' => 'https://gitlab.example.test',
+ 'access_token' => 'gitlab-access-token',
+ 'refresh_token' => 'gitlab-refresh-token',
+ 'expires_at' => time() + 3600,
+ ]);
+
+ $settings = (object) [
+ 'is_git_shallow_clone_enabled' => false,
+ 'is_git_submodules_enabled' => true,
+ ];
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->git_branch = 'main';
+ $application->shouldReceive('deploymentType')->andReturn('source');
+ $application->shouldReceive('customRepository')->andReturn([
+ 'repository' => 'root/qa-private-app',
+ 'port' => 22,
+ ]);
+ $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
+ $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
+ $application->source = $gitlabSource;
+
+ $result = $application->generateGitImportCommands(
+ deployment_uuid: $deploymentUuid,
+ pull_request_id: 2,
+ exec_in_docker: false,
+ only_checkout: true,
+ custom_base_dir: '/artifacts/test',
+ );
+
+ $commands = $result['commands'];
+ // The MR-ref fetch and submodule update must run through the OAuth-rewritten git, or private same-host submodules fail.
+ expect($commands)
+ ->toContain('oauth2:gitlab-access-token@gitlab.example.test')
+ ->toContain("http.version=HTTP/1.1 fetch origin 'merge-requests/2/head:pr-2-coolify'")
+ ->toContain('http.version=HTTP/1.1 submodule update --init --recursive');
+});
diff --git a/tests/Unit/Policies/GitlabAppPolicyTest.php b/tests/Unit/Policies/GitlabAppPolicyTest.php
new file mode 100644
index 000000000..a2cd2a091
--- /dev/null
+++ b/tests/Unit/Policies/GitlabAppPolicyTest.php
@@ -0,0 +1,172 @@
+makePartial();
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows any user to view system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: true);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('allows team member to view non-system-wide gitlab app', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view non-system-wide gitlab app', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows user with system access to update system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: true);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to update system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: true);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to update non-system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update non-system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows user with system access to delete system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: true);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to delete system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: true);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete non-system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete non-system-wide gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of gitlab app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = mockGitlabApp(teamId: 1, isSystemWide: false);
+
+ $policy = new GitlabAppPolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
+
+function mockGitlabApp(int $teamId, bool $isSystemWide): GitlabApp
+{
+ $gitlabApp = Mockery::mock(GitlabApp::class)->makePartial();
+ $gitlabApp->team_id = $teamId;
+ $gitlabApp->is_system_wide = $isSystemWide;
+
+ return $gitlabApp;
+}