mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(api): add Gitea App endpoints for sources and applications
Add GET /gitea-apps, POST /applications/private-gitea-app, and
gitea_app_uuid in PATCH /applications/{uuid}, with OpenAPI docs and MCP tool support.
Closes #12459
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4bdf03db69
commit
3bc82eb71d
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GiteaApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Http::GitSource() pins DNS before Http::fake() answers, so use a public IP
|
||||
* literal that needs no DNS lookup. No request leaves the test.
|
||||
*/
|
||||
const GITEA_API_TEST_URL = 'https://1.1.1.1';
|
||||
|
||||
function giteaApiTestSource(Team $team, array $attributes = []): GiteaApp
|
||||
{
|
||||
return GiteaApp::create([
|
||||
'name' => 'Team Gitea',
|
||||
'api_url' => GITEA_API_TEST_URL.'/api/v1',
|
||||
'html_url' => GITEA_API_TEST_URL,
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'access_token' => 'gitea-access-token',
|
||||
'refresh_token' => 'gitea-refresh-token',
|
||||
'webhook_secret' => 'gitea-webhook-secret',
|
||||
'expires_at' => time() + 3600,
|
||||
'team_id' => $team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
function fakeGiteaRepository(string $path = 'acme/app', string $owner = 'acme', int $id = 4242): void
|
||||
{
|
||||
Http::fake([
|
||||
GITEA_API_TEST_URL."/api/v1/repos/{$path}" => Http::response(['id' => $id, 'full_name' => $path, 'owner' => ['login' => $owner]]),
|
||||
'*' => Http::response(['message' => 'Not Found'], 404),
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
Server::flushIdentityMap();
|
||||
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->headers = ['Authorization' => 'Bearer '.$this->user->createToken('gitea-api-test', ['*'])->plainTextToken];
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
|
||||
$this->giteaApp = giteaApiTestSource($this->team);
|
||||
});
|
||||
|
||||
function createGiteaApplicationPayload(object $test, array $overrides = []): array
|
||||
{
|
||||
return [
|
||||
'project_uuid' => $test->project->uuid,
|
||||
'environment_uuid' => $test->environment->uuid,
|
||||
'server_uuid' => $test->server->uuid,
|
||||
'gitea_app_uuid' => $test->giteaApp->uuid,
|
||||
'git_repository' => 'acme/app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'autogenerate_domain' => false,
|
||||
...$overrides,
|
||||
];
|
||||
}
|
||||
|
||||
describe('GET /api/v1/gitea-apps', function () {
|
||||
test('lists team and system-wide sources without secrets for read tokens', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
giteaApiTestSource($otherTeam, ['name' => 'Foreign System Gitea', 'is_system_wide' => true]);
|
||||
giteaApiTestSource($otherTeam, ['name' => 'Foreign Private Gitea']);
|
||||
giteaApiTestSource($this->team, ['name' => 'Unconnected Gitea', 'access_token' => null, 'refresh_token' => null]);
|
||||
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$readToken])
|
||||
->getJson('/api/v1/gitea-apps')
|
||||
->assertOk()
|
||||
->assertJsonCount(3);
|
||||
|
||||
$apps = collect($response->json())->keyBy('name');
|
||||
expect($apps->keys()->sort()->values()->all())->toBe(['Foreign System Gitea', 'Team Gitea', 'Unconnected Gitea'])
|
||||
->and($apps['Team Gitea']['is_connected'])->toBeTrue()
|
||||
->and($apps['Unconnected Gitea']['is_connected'])->toBeFalse();
|
||||
foreach ($apps as $app) {
|
||||
expect($app)->not->toHaveKeys(['client_secret', 'access_token', 'refresh_token', 'webhook_secret']);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows secrets of own sources to root tokens only', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
giteaApiTestSource($otherTeam, ['name' => 'Foreign System Gitea', 'is_system_wide' => true]);
|
||||
|
||||
$apps = collect($this->withHeaders($this->headers)->getJson('/api/v1/gitea-apps')->assertOk()->json())->keyBy('name');
|
||||
|
||||
expect($apps['Team Gitea']['client_secret'])->toBe('client-secret')
|
||||
->and($apps['Foreign System Gitea'])->not->toHaveKey('client_secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/private-gitea-app', function () {
|
||||
test('creates an application from a repository URL on the Gitea instance', function () {
|
||||
fakeGiteaRepository();
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, [
|
||||
'git_repository' => GITEA_API_TEST_URL.'/acme/app.git',
|
||||
]))
|
||||
->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
expect($application->source_type)->toBe(GiteaApp::class)
|
||||
->and($application->source_id)->toBe($this->giteaApp->id)
|
||||
->and($application->git_repository)->toBe('acme/app')
|
||||
->and($application->repository_project_id)->toBe(4242);
|
||||
Http::assertSent(fn ($request) => $request->url() === GITEA_API_TEST_URL.'/api/v1/repos/acme/app'
|
||||
&& $request->hasHeader('Authorization', 'Bearer gitea-access-token'));
|
||||
});
|
||||
|
||||
test('returns 404 for a Gitea App of another team or one that is not connected', function (string $case) {
|
||||
$source = $case === 'other team'
|
||||
? giteaApiTestSource(Team::factory()->create())
|
||||
: giteaApiTestSource($this->team, ['access_token' => null, 'refresh_token' => null]);
|
||||
fakeGiteaRepository();
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['gitea_app_uuid' => $source->uuid]))
|
||||
->assertNotFound()
|
||||
->assertJson(['message' => 'Gitea App not found.']);
|
||||
|
||||
expect(Application::count())->toBe(0);
|
||||
Http::assertNothingSent();
|
||||
})->with(['other team', 'not connected']);
|
||||
|
||||
test('rejects a repository of an owner outside the source organizations', function () {
|
||||
$this->giteaApp->update(['organization' => 'acme']);
|
||||
fakeGiteaRepository('intruder/app', 'intruder');
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['git_repository' => 'intruder/app']))
|
||||
->assertNotFound()
|
||||
->assertJson(['message' => 'Repository not found or not accessible by the Gitea App.']);
|
||||
|
||||
expect(Application::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('rejects a repository that is not on the Gitea instance of the source', function (string $repository) {
|
||||
fakeGiteaRepository();
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['git_repository' => $repository]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('git_repository');
|
||||
|
||||
Http::assertNothingSent();
|
||||
})->with([
|
||||
'other host' => 'https://gitea.example.org/acme/app',
|
||||
'path traversal' => '../admin',
|
||||
'nested path' => 'acme/app/extra',
|
||||
]);
|
||||
|
||||
test('requires gitea_app_uuid', function () {
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['gitea_app_uuid' => null]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('gitea_app_uuid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} gitea_app_uuid', function () {
|
||||
beforeEach(function () {
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => StandaloneDocker::where('server_id', $this->server->id)->firstOrFail()->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'git_repository' => GITEA_API_TEST_URL.'/acme/app',
|
||||
'repository_project_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('moves a public repository application to a Gitea App source', function () {
|
||||
fakeGiteaRepository();
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", ['gitea_app_uuid' => $this->giteaApp->uuid])
|
||||
->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->source_type)->toBe(GiteaApp::class)
|
||||
->and($this->application->source_id)->toBe($this->giteaApp->id)
|
||||
->and($this->application->git_repository)->toBe('acme/app')
|
||||
->and($this->application->repository_project_id)->toBe(4242);
|
||||
});
|
||||
|
||||
test('does not move the application to a Gitea App of another team', function () {
|
||||
$foreignSource = giteaApiTestSource(Team::factory()->create());
|
||||
$originalSource = [$this->application->source_type, $this->application->source_id];
|
||||
fakeGiteaRepository();
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", ['gitea_app_uuid' => $foreignSource->uuid])
|
||||
->assertNotFound();
|
||||
|
||||
$this->application->refresh();
|
||||
expect([$this->application->source_type, $this->application->source_id])->toBe($originalSource);
|
||||
});
|
||||
|
||||
test('rejects github_app_uuid and gitea_app_uuid together', function () {
|
||||
$this->withHeaders($this->headers)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'gitea_app_uuid' => $this->giteaApp->uuid,
|
||||
'github_app_uuid' => 'any-github-app',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('gitea_app_uuid');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user