mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 00:33:44 -04:00
Require deploy permission for API changes that trigger server side effects, including resource deletion, storage access, provider operations, and database imports. Also escape Docker cleanup filters and sanitize log drain server names.
384 lines
15 KiB
PHP
384 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\SwarmDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Str;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config([
|
|
'cache.default' => 'array',
|
|
'session.driver' => 'array',
|
|
'queue.default' => 'sync',
|
|
'app.maintenance.driver' => 'file',
|
|
'constants.ssh.mux_enabled' => false,
|
|
]);
|
|
// Creating a standalone destination lists the Docker networks on the server first.
|
|
Process::fake();
|
|
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
|
|
['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->bearerToken = destinationsApiToken($this->user, $this->team, ['*']);
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
|
});
|
|
|
|
function destinationsApiHeaders(string $bearerToken): array
|
|
{
|
|
return [
|
|
'Authorization' => 'Bearer '.$bearerToken,
|
|
'Content-Type' => 'application/json',
|
|
];
|
|
}
|
|
|
|
function destinationsApiToken(User $user, Team $team, array $abilities): string
|
|
{
|
|
$plainTextToken = Str::random(40);
|
|
$token = $user->tokens()->create([
|
|
'name' => 'destinations-api-test-'.Str::random(6),
|
|
'token' => hash('sha256', $plainTextToken),
|
|
'abilities' => $abilities,
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
return $token->getKey().'|'.$plainTextToken;
|
|
}
|
|
|
|
describe('GET /api/v1/destinations', function () {
|
|
test('lists only destinations owned by the token team', function () {
|
|
$otherTeam = Team::factory()->create();
|
|
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
|
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->getJson('/api/v1/destinations');
|
|
|
|
$response->assertOk();
|
|
$uuids = collect($response->json())->pluck('uuid');
|
|
|
|
expect($response->json('0'))->not->toHaveKey('id')
|
|
->and($uuids)->toContain($this->destination->uuid)
|
|
->not->toContain($otherDestination->uuid);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/destinations/{uuid}', function () {
|
|
test('does not expose another team destination', function () {
|
|
$otherTeam = Team::factory()->create();
|
|
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
|
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->getJson("/api/v1/destinations/{$otherDestination->uuid}");
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/servers/{server_uuid}/destinations', function () {
|
|
test('lists destinations for a team server', function () {
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->getJson("/api/v1/servers/{$this->server->uuid}/destinations");
|
|
|
|
$response->assertOk();
|
|
expect($response->json())->toHaveCount(1)
|
|
->and($response->json('0.uuid'))->toBe($this->destination->uuid);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
|
|
test('creates a standalone destination', function () {
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'name' => 'API Standalone',
|
|
'network' => 'api-standalone-network',
|
|
]);
|
|
|
|
$response->assertCreated()
|
|
->assertJson([
|
|
'name' => 'API Standalone',
|
|
'network' => 'api-standalone-network',
|
|
'type' => 'standalone',
|
|
'server_uuid' => $this->server->uuid,
|
|
]);
|
|
|
|
expect(StandaloneDocker::where('server_id', $this->server->id)
|
|
->where('network', 'api-standalone-network')
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
test('requires a write token', function () {
|
|
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => 'new-network',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
});
|
|
|
|
test('rejects create requests from non-admin team members', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
$memberToken = destinationsApiToken($member, $this->team, ['*']);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => 'member-network',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', 'member-network')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('rejects non-json requests before creating a destination', function () {
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$this->bearerToken,
|
|
])->post("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => 'api-swarm-network',
|
|
'type' => 'swarm',
|
|
]);
|
|
|
|
$response->assertStatus(400);
|
|
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('rejects unknown fields', function () {
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => 'new-network',
|
|
'unexpected' => 'value',
|
|
]);
|
|
|
|
$response->assertUnprocessable();
|
|
$response->assertJsonValidationErrors(['unexpected']);
|
|
});
|
|
|
|
test('rejects unsafe docker network names', function () {
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => 'bad;network',
|
|
]);
|
|
|
|
$response->assertUnprocessable();
|
|
$response->assertJsonValidationErrors(['network']);
|
|
});
|
|
|
|
test('rejects a destination type that does not match the server mode', function () {
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => 'wrong-type-network',
|
|
'type' => 'swarm',
|
|
]);
|
|
|
|
$response->assertUnprocessable();
|
|
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'wrong-type-network')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('creates a swarm destination on a swarm server', function () {
|
|
$this->server->settings()->update(['is_swarm_manager' => true]);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'name' => 'API Swarm',
|
|
'network' => 'api-swarm-network',
|
|
'type' => 'swarm',
|
|
]);
|
|
|
|
$response->assertCreated();
|
|
$response->assertJsonStructure(['uuid']);
|
|
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('rejects duplicate networks on the same server and type', function () {
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => $this->destination->network,
|
|
]);
|
|
|
|
$response->assertStatus(409);
|
|
});
|
|
|
|
test('returns conflict when the database unique constraint wins a create race', function () {
|
|
$network = 'raced-network';
|
|
|
|
StandaloneDocker::creating(function (StandaloneDocker $destination) use ($network) {
|
|
if ($destination->network !== $network) {
|
|
return;
|
|
}
|
|
|
|
DB::table('standalone_dockers')->insert([
|
|
'name' => 'Concurrent destination',
|
|
'uuid' => (string) Str::uuid(),
|
|
'network' => $network,
|
|
'server_id' => $destination->server_id,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
});
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
|
'network' => $network,
|
|
]);
|
|
|
|
$response->assertStatus(409)
|
|
->assertJson(['message' => 'A destination with this network already exists on the server.']);
|
|
|
|
expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', $network)->count())->toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('PATCH /api/v1/destinations/{uuid}', function () {
|
|
test('updates destination name only', function () {
|
|
$originalNetwork = $this->destination->network;
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
|
'name' => 'Renamed Destination',
|
|
]);
|
|
|
|
$response->assertOk()
|
|
->assertJson([
|
|
'uuid' => $this->destination->uuid,
|
|
'name' => 'Renamed Destination',
|
|
'network' => $originalNetwork,
|
|
'type' => 'standalone',
|
|
'server_uuid' => $this->server->uuid,
|
|
]);
|
|
|
|
$this->destination->refresh();
|
|
expect($this->destination->name)->toBe('Renamed Destination')
|
|
->and($this->destination->network)->toBe($originalNetwork);
|
|
});
|
|
|
|
test('requires a write token', function () {
|
|
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
|
|
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
|
'name' => 'Should Fail',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
});
|
|
|
|
test('rejects update requests from non-admin team members', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
$memberToken = destinationsApiToken($member, $this->team, ['*']);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
|
|
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
|
'name' => 'Member Rename',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
});
|
|
|
|
test('rejects network changes and unknown fields', function () {
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
|
'name' => 'Valid Name',
|
|
'network' => 'new-network',
|
|
]);
|
|
|
|
$response->assertUnprocessable();
|
|
$response->assertJsonValidationErrors(['network']);
|
|
});
|
|
|
|
test('does not update another team destination', function () {
|
|
$otherTeam = Team::factory()->create();
|
|
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
|
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->patchJson("/api/v1/destinations/{$otherDestination->uuid}", [
|
|
'name' => 'Stolen Name',
|
|
]);
|
|
|
|
$response->assertNotFound();
|
|
expect($otherDestination->fresh()->name)->not->toBe('Stolen Name');
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/v1/destinations/{uuid}', function () {
|
|
test('requires a write token', function () {
|
|
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
|
|
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
|
|
|
|
$response->assertForbidden();
|
|
$this->assertModelExists($this->destination);
|
|
});
|
|
|
|
test('rejects delete requests from non-admin team members', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
$memberToken = destinationsApiToken($member, $this->team, ['*']);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
|
|
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
|
|
|
|
$response->assertForbidden();
|
|
$this->assertModelExists($this->destination);
|
|
});
|
|
|
|
test('deletes standalone destinations after removing the docker network', function () {
|
|
$cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class);
|
|
$cleanup->shouldReceive('handle')
|
|
->once()
|
|
->with(Mockery::on(fn (StandaloneDocker $destination) => $destination->is($this->destination)));
|
|
$this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
|
|
|
|
$response->assertOk();
|
|
$this->assertModelMissing($this->destination);
|
|
});
|
|
|
|
test('blocks deleting a destination with an attached service', function () {
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$environment = $project->environments()->first();
|
|
|
|
Service::factory()->create([
|
|
'environment_id' => $environment->id,
|
|
'server_id' => $this->server->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
|
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
|
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
|
|
|
|
$response->assertStatus(409);
|
|
$this->assertModelExists($this->destination);
|
|
});
|
|
});
|