mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-18 18:15:18 +00:00
Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react
This commit is contained in:
commit
c9416abec6
117 files changed
+5121
-795
No files matched your search
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
||||
use App\Models\InstanceSettings;
|
||||
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\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
'queue.default' => 'sync',
|
||||
'app.maintenance.driver' => 'file',
|
||||
]);
|
||||
|
||||
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]);
|
||||
$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('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('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);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,9 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
$this->withoutVite();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CanUpdateResource;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function requestWithCanUpdateResourceRouteParameter(string $parameter, ?string $value): Request
|
||||
{
|
||||
$parameters = [
|
||||
'application_uuid' => null,
|
||||
'database_uuid' => null,
|
||||
'stack_service_uuid' => null,
|
||||
'service_uuid' => null,
|
||||
'server_uuid' => null,
|
||||
'environment_uuid' => null,
|
||||
'project_uuid' => null,
|
||||
$parameter => $value,
|
||||
];
|
||||
|
||||
$request = Mockery::mock(Request::class)->makePartial();
|
||||
$request->shouldReceive('route')->andReturnUsing(fn (string $key): ?string => $parameters[$key] ?? null);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->member = User::factory()->create();
|
||||
$this->member->teams()->attach($this->team, ['role' => 'member']);
|
||||
});
|
||||
|
||||
it('blocks members from update-only project routes before the page renders', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
(new CanUpdateResource)->handle(
|
||||
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
|
||||
fn () => response('ok')
|
||||
);
|
||||
})->throws(HttpException::class, 'You do not have permission to update this resource.');
|
||||
|
||||
it('allows admins through update-only project routes', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = (new CanUpdateResource)->handle(
|
||||
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
|
||||
fn () => response('ok')
|
||||
);
|
||||
|
||||
expect($response->getContent())->toBe('ok');
|
||||
});
|
||||
|
||||
it('blocks members from update-only server routes before the page renders', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
(new CanUpdateResource)->handle(
|
||||
requestWithCanUpdateResourceRouteParameter('server_uuid', $this->server->uuid),
|
||||
fn () => response('ok')
|
||||
);
|
||||
})->throws(HttpException::class, 'You do not have permission to update this resource.');
|
||||
|
||||
it('returns not found when an update-only route references an unknown resource', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
(new CanUpdateResource)->handle(
|
||||
requestWithCanUpdateResourceRouteParameter('project_uuid', 'not-a-project'),
|
||||
fn () => response('ok')
|
||||
);
|
||||
})->throws(NotFoundHttpException::class, 'Resource not found.');
|
||||
@@ -126,6 +126,18 @@ test('member cannot submit team settings via policy', function () {
|
||||
expect(auth()->user()->can('update', $this->team))->toBeFalse();
|
||||
});
|
||||
|
||||
test('owner can update team MCP setting', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(TeamIndex::class)
|
||||
->set('is_mcp_server_enabled', false)
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->team->fresh()->is_mcp_server_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
// --- Team Index Livewire: delete ---
|
||||
|
||||
test('member cannot delete team via index', function () {
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -43,6 +44,20 @@ function createBackupForEditValidationTest(Team $team, array $overrides = []): S
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function createS3StorageForBackupEditValidationTest(Team|int $team, string $name = 'Backup Edit S3'): S3Storage
|
||||
{
|
||||
return S3Storage::create([
|
||||
'name' => $name,
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => true,
|
||||
'team_id' => $team instanceof Team ? $team->id : $team,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
if (InstanceSettings::find(0) === null) {
|
||||
$settings = new InstanceSettings;
|
||||
@@ -60,7 +75,7 @@ beforeEach(function () {
|
||||
it('disables S3 backup when saved without a selected S3 storage', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
@@ -74,7 +89,7 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
|
||||
'disable_local_backup' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
@@ -83,3 +98,110 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
expect($backup->disable_local_backup)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('keeps S3 enabled by selecting the only available team storage when none is selected yet', function () {
|
||||
createS3StorageForBackupEditValidationTest(Team::factory()->create());
|
||||
$s3 = createS3StorageForBackupEditValidationTest($this->team);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->set('saveS3', true)
|
||||
->call('instantSave')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($s3->id);
|
||||
});
|
||||
|
||||
it('defaults to the first available storage when multiple storages are available', function () {
|
||||
$firstS3 = createS3StorageForBackupEditValidationTest($this->team, 'First S3');
|
||||
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSet('s3StorageId', $firstS3->id)
|
||||
->set('saveS3', true)
|
||||
->call('instantSave')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($firstS3->id);
|
||||
});
|
||||
|
||||
it('accepts the S3 storage scope passed to the component', function () {
|
||||
$s3 = createS3StorageForBackupEditValidationTest(0);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])])
|
||||
->set('saveS3', true)
|
||||
->set('s3StorageId', $s3->id)
|
||||
->call('instantSave')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($s3->id);
|
||||
});
|
||||
|
||||
it('shows available S3 storages even when S3 backup is disabled', function () {
|
||||
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
|
||||
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSee('First S3')
|
||||
->assertSee('Second S3');
|
||||
});
|
||||
|
||||
it('shows disabled S3 storage dropdown when no storages are available', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSee('No S3 storage available');
|
||||
});
|
||||
|
||||
it('shows when S3 backups are currently disabled', function () {
|
||||
createS3StorageForBackupEditValidationTest($this->team);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSee('S3 Storage')
|
||||
->assertSee('(currently disabled)');
|
||||
});
|
||||
|
||||
it('saves selected S3 storage immediately when it changes', function () {
|
||||
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
|
||||
$secondS3 = createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->set('s3StorageId', $secondS3->id)
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBe($secondS3->id);
|
||||
});
|
||||
@@ -56,7 +56,7 @@ it('filters production environment variables by key case-insensitively', functio
|
||||
->toBe(['API_KEY']);
|
||||
});
|
||||
|
||||
it('treats production environment variable search wildcards literally', function () {
|
||||
it('treats production environment variable search underscore wildcards literally', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
@@ -75,23 +75,11 @@ it('treats production environment variable search wildcards literally', function
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'PERCENT%KEY',
|
||||
'value' => 'percent-secret',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(All::class, ['resource' => $application])
|
||||
->set('search', 'api_key');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['API_KEY']);
|
||||
|
||||
$component->set('search', '%KEY');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['PERCENT%KEY']);
|
||||
});
|
||||
|
||||
it('filters preview environment variables by key case-insensitively', function () {
|
||||
@@ -142,6 +130,34 @@ YAML,
|
||||
->toBe(['API_TOKEN']);
|
||||
});
|
||||
|
||||
it('searches service environment variables without requiring preview variables', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'secret',
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $service->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://example',
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $service->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(All::class, ['resource' => $service])
|
||||
->set('search', 'api')
|
||||
->assertSee('Production Environment Variables')
|
||||
->assertDontSee('Preview Deployments Environment Variables');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['API_KEY']);
|
||||
});
|
||||
|
||||
it('does not show the empty production message when search only matches hardcoded variables', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
@@ -155,11 +171,13 @@ services:
|
||||
YAML,
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $service])
|
||||
$component = Livewire::test(All::class, ['resource' => $service])
|
||||
->set('search', 'api')
|
||||
->assertSee('Production Environment Variables')
|
||||
->assertSee('API_TOKEN')
|
||||
->assertDontSee('No environment variables found.');
|
||||
|
||||
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
|
||||
->toBe(['API_TOKEN']);
|
||||
});
|
||||
|
||||
it('keeps developer view unfiltered after searching', function () {
|
||||
@@ -242,10 +260,12 @@ it('hides the preview section when search filters out all preview variables', fu
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $application])
|
||||
$component = Livewire::test(All::class, ['resource' => $application])
|
||||
->set('search', 'api')
|
||||
->assertSee('Production Environment Variables')
|
||||
->assertSee('API_KEY')
|
||||
->assertDontSee('Preview Deployments Environment Variables')
|
||||
->assertDontSee('PREVIEW_TOKEN');
|
||||
|
||||
expect($component->instance()->environmentVariables->pluck('key')->all())
|
||||
->toBe(['API_KEY']);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
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\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||
Bus::fake();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$keyId = DB::table('private_keys')->insertGetId([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-key',
|
||||
'team_id' => $this->team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $keyId,
|
||||
]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $this->server->id, 'network' => 'coolify'],
|
||||
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Project',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->environment = $this->project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test App',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_path', '/../../../../../../etc/example.conf')
|
||||
->set('file_storage_content', 'owned')
|
||||
->call('submitFileStorage')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(LocalFileVolume::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('file mount modal shows the calculated host file path above the destination input', function () {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->assertSeeText('This file will be created on the host, then mounted into the container.')
|
||||
->assertSeeText('Host file path')
|
||||
->assertSeeText($this->application->workdir().'/')
|
||||
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
||||
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
|
||||
->assertDontSeeText('Actual file mounted from the host system to the container.');
|
||||
});
|
||||
|
||||
test('livewire file storage stores safe file mounts under the application configuration root', function () {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
||||
->set('file_storage_content', 'server {}')
|
||||
->call('submitFileStorage')
|
||||
->assertDispatched('success');
|
||||
|
||||
$volume = LocalFileVolume::query()->sole();
|
||||
|
||||
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
|
||||
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
|
||||
->and($volume->is_directory)->toBeFalse();
|
||||
});
|
||||
|
||||
test('livewire host file storage stores an existing host file path without managed content', function () {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
|
||||
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
|
||||
->call('submitHostFileStorage')
|
||||
->assertDispatched('success');
|
||||
|
||||
$volume = LocalFileVolume::query()->sole();
|
||||
|
||||
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
|
||||
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
|
||||
->and($volume->content)->toBeNull()
|
||||
->and($volume->is_host_file)->toBeTrue()
|
||||
->and($volume->is_directory)->toBeFalse();
|
||||
|
||||
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
||||
});
|
||||
@@ -262,3 +262,11 @@ test('s3 storage has scheduled backups relationship', function () {
|
||||
|
||||
expect($s3->scheduledBackups()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('database backup job escapes the S3 copy destination argument', function () {
|
||||
$source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
|
||||
|
||||
expect($source)->toContain('$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");')
|
||||
->and($source)->toContain('mc cp {$escapedBackupLocation} {$escapedS3Destination}')
|
||||
->and($source)->not->toContain('mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/');
|
||||
});
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
|
||||
it('uses safe domain validation rules in the application general form', function () {
|
||||
$component = new General;
|
||||
$method = new ReflectionMethod($component, 'rules');
|
||||
$rules = $method->invoke($component);
|
||||
|
||||
$validator = validator([
|
||||
'fqdn' => 'http://$(whoami).example.com',
|
||||
], [
|
||||
'fqdn' => $rules['fqdn'],
|
||||
]);
|
||||
|
||||
expect($validator->fails())->toBeTrue()
|
||||
->and($validator->errors()->has('fqdn'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('uses safe docker compose service domain validation rules in the application general form', function () {
|
||||
$component = new General;
|
||||
$method = new ReflectionMethod($component, 'rules');
|
||||
$rules = $method->invoke($component);
|
||||
|
||||
$validator = validator([
|
||||
'parsedServiceDomains' => [
|
||||
'app' => [
|
||||
'domain' => 'http://$(whoami).example.com',
|
||||
],
|
||||
],
|
||||
], [
|
||||
'parsedServiceDomains.*.domain' => $rules['parsedServiceDomains.*.domain'],
|
||||
]);
|
||||
|
||||
expect($validator->fails())->toBeTrue()
|
||||
->and($validator->errors()->has('parsedServiceDomains.app.domain'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('uses safe docker registry image validation rules in the application general form', function () {
|
||||
$component = new General;
|
||||
$method = new ReflectionMethod($component, 'rules');
|
||||
|
||||
@@ -90,6 +90,16 @@ test('MCP endpoint returns 404 when the instance setting is disabled', function
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('MCP endpoint returns 403 when the token team has MCP disabled', function () {
|
||||
$this->team->update(['is_mcp_server_enabled' => false]);
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpListTools($token);
|
||||
|
||||
$response->assertForbidden();
|
||||
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
|
||||
});
|
||||
|
||||
test('MCP endpoint rejects unauthenticated requests', function () {
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(401);
|
||||
|
||||
@@ -130,6 +130,69 @@ describe('deployment job path field validation', function () {
|
||||
});
|
||||
|
||||
describe('API validation rules for path fields', function () {
|
||||
test('domains validation rejects command injection payloads', function (string $payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['domains' => $payload],
|
||||
['domains' => $rules['domains']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'host command substitution' => 'http://$(whoami).example.com',
|
||||
'path command substitution' => 'http://example.com/$(whoami)',
|
||||
'query command substitution' => 'http://example.com/path?next=$(id)',
|
||||
'host backtick substitution' => 'http://`whoami`.example.com',
|
||||
'path backtick substitution' => 'http://example.com/`whoami`',
|
||||
'semicolon command separator' => 'http://example.com/path;id',
|
||||
'newline injection' => "http://example.com\nwhoami",
|
||||
'carriage return injection' => "http://example.com\rwhoami",
|
||||
'pipe injection' => 'http://example.com/path|id',
|
||||
]);
|
||||
|
||||
test('domains validation rejects non http schemes', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['domains' => 'ftp://example.com'],
|
||||
['domains' => $rules['domains']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('domains validation allows comma separated http and https urls', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['domains' => 'https://app.example.com,http://api.example.com/path'],
|
||||
['domains' => $rules['domains']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('docker compose service domains validation rejects command injection payloads', function () {
|
||||
$rules = [
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
];
|
||||
|
||||
$validator = validator(
|
||||
[
|
||||
'docker_compose_domains' => [
|
||||
['name' => 'app', 'domain' => 'https://app.example.com/$(whoami)'],
|
||||
],
|
||||
],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('git_branch validation rejects shell metacharacters', function (string $branch) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
@@ -276,7 +339,45 @@ describe('deployment git command escaping', function () {
|
||||
|
||||
expect($coolifyVariables->getValue($instance))
|
||||
->toContain("COOLIFY_BRANCH='main`id`' ")
|
||||
->toContain('COOLIFY_RESOURCE_UUID=app-uuid ');
|
||||
->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
|
||||
});
|
||||
|
||||
test('coolify url and fqdn shell assignments are quoted', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
$application = new Application;
|
||||
$application->uuid = 'app-uuid';
|
||||
$application->git_branch = 'main';
|
||||
$application->fqdn = 'https://app.example.com/path';
|
||||
$application->compose_parsing_version = '3';
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->include_source_commit_in_build = true;
|
||||
$application->setRelation('settings', $settings);
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'commit' => 'HEAD$(id)',
|
||||
'pull_request_id' => 0,
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $job->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($instance, $value);
|
||||
}
|
||||
|
||||
$method = $job->getMethod('set_coolify_variables');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($instance);
|
||||
|
||||
$coolifyVariables = $job->getProperty('coolify_variables');
|
||||
$coolifyVariables->setAccessible(true);
|
||||
|
||||
expect($coolifyVariables->getValue($instance))
|
||||
->toContain("SOURCE_COMMIT='HEAD$(id)' ")
|
||||
->toContain("COOLIFY_URL='https://app.example.com/path' ")
|
||||
->toContain("COOLIFY_FQDN='app.example.com' ")
|
||||
->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,3 +87,32 @@ it('renders the service templates last updated hint placeholder', function () {
|
||||
$view->assertSee('serviceTemplatesLastUpdated');
|
||||
$view->assertSee('service.templateLastUpdated');
|
||||
});
|
||||
|
||||
it('keeps service template keys for service selection and docs links', function () {
|
||||
$services = collect((new Select)->loadServices()['services']);
|
||||
$denoKv = $services->firstWhere('id', 'denoKV');
|
||||
|
||||
expect($denoKv)
|
||||
->not->toBeNull()
|
||||
->and($denoKv['docsSlug'])->toBe('denokv');
|
||||
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
$view = $this->view('livewire.project.new.select', [
|
||||
'current_step' => 'type',
|
||||
'environments' => collect(),
|
||||
]);
|
||||
|
||||
$view->assertSee("setType('one-click-service-' + service.id)", false);
|
||||
$view->assertSee('service.docsSlug || this.extractBaseServiceName(service.name)', false);
|
||||
});
|
||||
|
||||
it('preserves one click service key casing when selecting a service template', function () {
|
||||
$component = new Select;
|
||||
$component->servers = collect();
|
||||
$component->allServers = collect();
|
||||
|
||||
$component->setType('one-click-service-denoKV');
|
||||
|
||||
expect($component->type)->toBe('one-click-service-denoKV');
|
||||
});
|
||||
@@ -26,7 +26,7 @@ test('non-admin user is redirected from settings updates page', function () {
|
||||
test('instance admin can access settings updates page', function () {
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
@@ -39,3 +39,24 @@ test('instance admin can access settings updates page', function () {
|
||||
->assertOk()
|
||||
->assertNoRedirect();
|
||||
});
|
||||
|
||||
test('instance admin cannot save an invalid docker registry url', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$settings = InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->set('docker_registry_url', 'docker.io; touch /tmp/pwned')
|
||||
->call('instantSave')
|
||||
->assertHasErrors(['docker_registry_url' => 'in']);
|
||||
|
||||
expect($settings->fresh()->docker_registry_url)->toBe('docker.io');
|
||||
});
|
||||
@@ -28,6 +28,7 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
|
||||
->toContain('wire:click="openWhatsNewModal"')
|
||||
->toContain('class="relative text-left menu-item"')
|
||||
->toContain('class="text-left menu-item-label"')
|
||||
->toContain('class="absolute right-2 top-1/2 -translate-y-1/2 bg-error')
|
||||
->toContain("What's New</span>")
|
||||
->toContain('M9.813 15.904 9 18.75')
|
||||
->not->toContain('<span>Changelog</span>')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -7,6 +8,8 @@ use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
@@ -18,8 +21,9 @@ use Illuminate\Support\Str;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||
Bus::fake();
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
@@ -61,6 +65,24 @@ function createTestDatabase($context): StandalonePostgresql
|
||||
]);
|
||||
}
|
||||
|
||||
function createTestServiceApplication($context): array
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $context->environment->id,
|
||||
'destination_id' => $context->destination->id,
|
||||
'destination_type' => $context->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'test-service-app',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
return [$service, $serviceApplication];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Application Storage Endpoints
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -140,6 +162,56 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->mount_path)->toBe('/app/config.json');
|
||||
expect($vol->is_directory)->toBeFalse();
|
||||
expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
|
||||
});
|
||||
|
||||
test('creates bind only host file storage for application', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'is_host_file' => true,
|
||||
'fs_path' => '/etc/nginx/nginx.conf',
|
||||
'mount_path' => '/etc/nginx/nginx.conf',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalFileVolume::where('resource_id', $app->id)
|
||||
->where('resource_type', get_class($app))
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
|
||||
expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
|
||||
expect($vol->content)->toBeNull();
|
||||
expect($vol->is_host_file)->toBeTrue();
|
||||
expect($vol->is_directory)->toBeFalse();
|
||||
|
||||
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
||||
});
|
||||
|
||||
test('rejects file storage paths with parent segments', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/../../../../../../etc/example.conf',
|
||||
'content' => 'owned',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('message', 'Validation failed.');
|
||||
|
||||
expect(LocalFileVolume::where('resource_id', $app->id)
|
||||
->where('resource_type', get_class($app))
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects persistent storage without name', function () {
|
||||
@@ -331,6 +403,92 @@ describe('POST /api/v1/databases/{uuid}/storages', function () {
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->mount_path)->toBe('/extra');
|
||||
});
|
||||
|
||||
test('creates a file storage for a database under the database configuration root', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/postgres/postgresql.conf',
|
||||
'content' => 'listen_addresses = "*"',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalFileVolume::where('resource_id', $db->id)
|
||||
->where('resource_type', get_class($db))
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
|
||||
});
|
||||
|
||||
test('rejects file storage paths with parent segments for a database', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/postgres/../../../etc/shadow',
|
||||
'content' => 'owned',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
|
||||
expect(LocalFileVolume::where('resource_id', $db->id)
|
||||
->where('resource_type', get_class($db))
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/storages', function () {
|
||||
test('creates a file storage for a service resource under the service configuration root', function () {
|
||||
[$service, $serviceApplication] = createTestServiceApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/services/{$service->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'resource_uuid' => $serviceApplication->uuid,
|
||||
'mount_path' => '/etc/nginx/nginx.conf',
|
||||
'content' => 'server {}',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
|
||||
->where('resource_type', get_class($serviceApplication))
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
|
||||
});
|
||||
|
||||
test('rejects file storage paths with parent segments for a service resource', function () {
|
||||
[$service, $serviceApplication] = createTestServiceApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/services/{$service->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'resource_uuid' => $serviceApplication->uuid,
|
||||
'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
|
||||
'content' => 'owned',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
|
||||
expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
|
||||
->where('resource_type', get_class($serviceApplication))
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
|
||||
|
||||
Reference in new issue
Block a user