From b2fed043c5d6b45eea877f34c808d36dd683373f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:35:59 +0200 Subject: [PATCH] feat(api): add GitLab Apps CRUD endpoints There was no API for creating GitLab sources. Add /api/v1/gitlab-apps list/create/update/delete with OpenAPI docs, sensitive-field redaction, and feature coverage mirroring the GitHub Apps API. --- app/Http/Controllers/Api/GitlabController.php | 540 ++++++++++++++++++ routes/api.php | 8 +- tests/Feature/Api/GitlabAppsApiTest.php | 182 ++++++ 3 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/GitlabController.php create mode 100644 tests/Feature/Api/GitlabAppsApiTest.php diff --git a/app/Http/Controllers/Api/GitlabController.php b/app/Http/Controllers/Api/GitlabController.php new file mode 100644 index 000000000..c907af46f --- /dev/null +++ b/app/Http/Controllers/Api/GitlabController.php @@ -0,0 +1,540 @@ +attributes->get('can_read_sensitive', false) === true) { + $gitlabApp->makeVisible([ + 'client_secret', + 'webhook_token', + 'access_token', + 'refresh_token', + ]); + } else { + $gitlabApp->makeHidden([ + 'client_secret', + 'webhook_token', + 'access_token', + 'refresh_token', + ]); + } + + return serializeApiResponse($gitlabApp); + } + + private function findTeamGitlabApp(int|string $gitlabAppId, int $teamId): GitlabApp + { + return GitlabApp::where('id', $gitlabAppId) + ->where('team_id', $teamId) + ->firstOrFail(); + } + + private function gitlabApiUrlFromHtmlUrl(string $htmlUrl): string + { + return rtrim($htmlUrl, '/').'/api/v4'; + } + + #[OA\Get( + summary: 'List', + description: 'List all GitLab apps for the current team (and system-wide sources).', + path: '/gitlab-apps', + operationId: 'list-gitlab-apps', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + responses: [ + new OA\Response( + response: 200, + description: 'List of GitLab apps.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'client_id' => ['type' => 'string', 'nullable' => true], + 'group_name' => ['type' => 'string', 'nullable' => true], + 'redirect_uri' => ['type' => 'string', 'nullable' => true], + 'is_system_wide' => ['type' => 'boolean'], + 'is_public' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + ] + ) + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function list_gitlab_apps(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $gitlabApps = GitlabApp::where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + })->get(); + + $gitlabApps = $gitlabApps->map(function ($app) { + return $this->removeSensitiveData($app); + }); + + return response()->json($gitlabApps); + } + + #[OA\Post( + summary: 'Create GitLab App', + description: 'Create a new GitLab app (OAuth source). Credentials may be supplied later via the UI or update endpoint.', + path: '/gitlab-apps', + operationId: 'create-gitlab-app', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + requestBody: new OA\RequestBody( + description: 'GitLab app creation payload.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'Name of the GitLab app.'], + 'html_url' => ['type' => 'string', 'description' => 'GitLab instance URL (e.g., https://gitlab.com).'], + 'api_url' => ['type' => 'string', 'description' => 'GitLab API URL (defaults to {html_url}/api/v4).'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'], + 'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional comma-separated group names to filter repositories.'], + 'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application ID.'], + 'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application Secret.'], + 'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token (auto-generated when omitted).'], + 'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI registered in GitLab.'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (non-cloud instances only).'], + ], + required: ['name', 'html_url'], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'GitLab app created successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'client_id' => ['type' => 'string', 'nullable' => true], + 'group_name' => ['type' => 'string', 'nullable' => true], + 'redirect_uri' => ['type' => 'string', 'nullable' => true], + 'is_system_wide' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + ] + ) + ), + ] + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_gitlab_app(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $this->authorize('create', [GitlabApp::class]); + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $allowedFields = [ + 'name', + 'html_url', + 'api_url', + 'custom_user', + 'custom_port', + 'group_name', + 'client_id', + 'client_secret', + 'webhook_token', + 'redirect_uri', + 'is_system_wide', + ]; + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl], + 'custom_user' => 'nullable|string|max:255', + 'custom_port' => 'nullable|integer|min:1|max:65535', + 'group_name' => 'nullable|string|max:255', + 'client_id' => 'nullable|string|max:255', + 'client_secret' => 'nullable|string', + 'webhook_token' => 'nullable|string', + // Callback to this Coolify instance — may be a private/LAN URL; do not use SafeExternalUrl. + 'redirect_uri' => ['nullable', 'string', 'url'], + 'is_system_wide' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + try { + $htmlUrl = rtrim((string) $request->input('html_url'), '/'); + $apiUrl = filled($request->input('api_url')) + ? rtrim((string) $request->input('api_url'), '/') + : $this->gitlabApiUrlFromHtmlUrl($htmlUrl); + + $payload = [ + 'name' => $request->input('name'), + 'html_url' => $htmlUrl, + 'api_url' => $apiUrl, + 'custom_user' => $request->input('custom_user', 'git'), + 'custom_port' => $request->input('custom_port', 22), + 'group_name' => $request->input('group_name'), + 'client_id' => $request->input('client_id'), + 'client_secret' => $request->input('client_secret'), + 'webhook_token' => $request->input('webhook_token') ?: Str::random(32), + 'redirect_uri' => $request->input('redirect_uri'), + 'is_public' => false, + 'team_id' => $teamId, + ]; + + if (! isCloud()) { + $payload['is_system_wide'] = $request->boolean('is_system_wide', false); + } + + $gitlabApp = GitlabApp::create($payload); + + auditLog('api.gitlab_app.created', [ + 'team_id' => $teamId, + 'gitlab_app_uuid' => $gitlabApp->uuid, + 'gitlab_app_name' => $gitlabApp->name, + ]); + + return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201); + } catch (\Throwable $e) { + return handleError($e); + } + } + + #[OA\Patch( + path: '/gitlab-apps/{gitlab_app_id}', + operationId: 'updateGitlabApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + summary: 'Update GitLab App', + description: 'Update an existing GitLab app.', + parameters: [ + new OA\Parameter( + name: 'gitlab_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitLab App ID' + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'GitLab App name'], + 'html_url' => ['type' => 'string', 'description' => 'GitLab HTML URL'], + 'api_url' => ['type' => 'string', 'description' => 'GitLab API URL'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'], + 'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional group filter'], + 'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application ID'], + 'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application Secret'], + 'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token'], + 'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'], + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'GitLab app updated successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitLab app updated successfully'], + 'data' => ['type' => 'object', 'description' => 'Updated GitLab app data'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitLab app not found'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_gitlab_app(Request $request, $gitlab_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId); + $this->authorize('update', $gitlabApp); + + $allowedFields = [ + 'name', + 'html_url', + 'api_url', + 'custom_user', + 'custom_port', + 'group_name', + 'client_id', + 'client_secret', + 'webhook_token', + 'redirect_uri', + ]; + + if (! isCloud()) { + $allowedFields[] = 'is_system_wide'; + } + + $payload = $request->only($allowedFields); + + $rules = []; + if (isset($payload['name'])) { + $rules['name'] = 'string|max:255'; + } + if (isset($payload['html_url'])) { + $rules['html_url'] = ['url', new SafeExternalUrl]; + } + if (isset($payload['api_url'])) { + $rules['api_url'] = ['url', new SafeExternalUrl]; + } + if (isset($payload['custom_user'])) { + $rules['custom_user'] = 'string|max:255'; + } + if (isset($payload['custom_port'])) { + $rules['custom_port'] = 'integer|min:1|max:65535'; + } + if (array_key_exists('group_name', $payload)) { + $rules['group_name'] = 'nullable|string|max:255'; + } + if (array_key_exists('client_id', $payload)) { + $rules['client_id'] = 'nullable|string|max:255'; + } + if (array_key_exists('client_secret', $payload)) { + $rules['client_secret'] = 'nullable|string'; + } + if (array_key_exists('webhook_token', $payload)) { + $rules['webhook_token'] = 'nullable|string'; + } + if (array_key_exists('redirect_uri', $payload)) { + // Callback to this Coolify instance — may be a private/LAN URL. + $rules['redirect_uri'] = 'nullable|url'; + } + if (! isCloud() && isset($payload['is_system_wide'])) { + $rules['is_system_wide'] = 'boolean'; + } + + $validator = customApiValidator($payload, $rules); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation error', + 'errors' => $validator->errors(), + ], 422); + } + + if (isset($payload['html_url'])) { + $payload['html_url'] = rtrim((string) $payload['html_url'], '/'); + if (! filled($payload['api_url'] ?? null)) { + $payload['api_url'] = $this->gitlabApiUrlFromHtmlUrl($payload['html_url']); + } + } + if (isset($payload['api_url'])) { + $payload['api_url'] = rtrim((string) $payload['api_url'], '/'); + } + + $gitlabApp->update($payload); + + auditLog('api.gitlab_app.updated', [ + 'team_id' => $teamId, + 'gitlab_app_uuid' => $gitlabApp->uuid, + 'gitlab_app_name' => $gitlabApp->name, + 'changed_fields' => array_values(array_diff(array_keys($payload), ['client_secret', 'webhook_token'])), + ]); + + return response()->json([ + 'message' => 'GitLab app updated successfully', + 'data' => $this->removeSensitiveData($gitlabApp->fresh()), + ]); + } catch (ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitLab app not found', + ], 404); + } + } + + #[OA\Delete( + path: '/gitlab-apps/{gitlab_app_id}', + operationId: 'deleteGitlabApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + summary: 'Delete GitLab App', + description: 'Delete a GitLab app if it is not being used by any applications.', + parameters: [ + new OA\Parameter( + name: 'gitlab_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitLab App ID' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'GitLab app deleted successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitLab app deleted successfully'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitLab app not found'), + new OA\Response( + response: 409, + description: 'Conflict - GitLab app is in use', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'This GitLab app is being used by 5 application(s). Please delete all applications first.'], + ] + ) + ) + ), + ] + )] + public function delete_gitlab_app($gitlab_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId); + $this->authorize('delete', $gitlabApp); + + if ($gitlabApp->applications->isNotEmpty()) { + $count = $gitlabApp->applications->count(); + + return response()->json([ + 'message' => "This GitLab app is being used by {$count} application(s). Please delete all applications first.", + ], 409); + } + + $deletedUuid = $gitlabApp->uuid; + $deletedName = $gitlabApp->name; + $gitlabApp->delete(); + + auditLog('api.gitlab_app.deleted', [ + 'team_id' => $teamId, + 'gitlab_app_uuid' => $deletedUuid, + 'gitlab_app_name' => $deletedName, + ]); + + return response()->json([ + 'message' => 'GitLab app deleted successfully', + ]); + } catch (ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitLab app not found', + ], 404); + } + } +} 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/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(); + }); +});