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.
This commit is contained in:
Andras Bacsai
2026-07-21 21:35:59 +02:00
parent bce4f8d7b2
commit b2fed043c5
3 changed files with 729 additions and 1 deletions
@@ -0,0 +1,540 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GitlabApp;
use App\Rules\SafeExternalUrl;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
class GitlabController extends Controller
{
private function removeSensitiveData(GitlabApp $gitlabApp)
{
if (request()->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);
}
}
}
+7 -1
View File
@@ -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']);
+182
View File
@@ -0,0 +1,182 @@
<?php
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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();
});
});