coolify/tests/Feature/ServiceResourceRoutingTest.php

453 lines
18 KiB
PHP
Raw Normal View History

2026-05-22 11:38:28 +00:00
<?php
use App\Jobs\DatabaseBackupJob;
use App\Jobs\VolumeBackupJob;
2026-05-22 11:38:28 +00:00
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\Heading;
use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
2026-05-22 11:38:28 +00:00
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
2026-05-22 11:38:28 +00:00
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
2026-05-22 11:38:28 +00:00
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Queue;
2026-05-22 11:38:28 +00:00
use Illuminate\Support\Once;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('cache.default', 'array');
Config::set('app.maintenance.store', 'array');
Config::set('queue.default', 'sync');
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
Once::flush();
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'team-a-network',
]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'network' => 'team-b-network',
]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->otherService = Service::factory()->create([
'server_id' => $this->serverB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => $this->destinationB->getMorphClass(),
'environment_id' => $this->environmentB->id,
]);
$this->otherServiceApplication = ServiceApplication::create([
'service_id' => $this->otherService->id,
'name' => 'other-app',
'image' => 'nginx:alpine',
]);
$this->otherServiceDatabase = ServiceDatabase::create([
'service_id' => $this->otherService->id,
'name' => 'other-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->ownService = Service::factory()->create([
'server_id' => $this->serverA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
'environment_id' => $this->environmentA->id,
]);
$this->ownServiceDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'own-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('does not open service application detail route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceApplication->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not open service database backups route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not open service import backup route from another team', function () {
$this->get(route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
]))->assertForbidden();
});
2026-05-22 11:38:28 +00:00
test('does not resolve service database import component from another team', function () {
$component = app(DatabaseImport::class);
$component->parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
];
$component->getContainers();
})->throws(ModelNotFoundException::class);
test('service heading does not hydrate with another team service', function () {
Livewire::test(Heading::class, ['service' => $this->otherService]);
})->throws(ModelNotFoundException::class);
test('owner can still hydrate service heading with own service', function () {
Livewire::test(Heading::class, [
'service' => $this->ownService,
'parameters' => [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
],
])
->assertOk();
});
test('legacy service database backup detail urls redirect to unified backup views', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
'save_s3' => true,
]);
$listUrl = route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$generalUrl = $listUrl.'/'.$backup->uuid;
$parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
];
$this->get($generalUrl)->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/s3')->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/retention')->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/danger')->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/executions')->assertRedirect(route('project.service.volume-backups.index', $parameters));
});
test('legacy service database backup list redirects to unified service backups', function () {
$legacyUrl = route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$centralBackupsUrl = route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]);
$this->get($legacyUrl)->assertRedirect($centralBackupsUrl);
});
test('service backup schedules open in place from the unified view', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))
->assertOk()
->assertSee("wire:click=\"openSchedule('{$backup->uuid}')\"", false);
});
test('service database backup schedules open in the Livewire component', function () {
Queue::fake();
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->call('openSchedule', $backup->uuid)
->assertSet('scheduleModalOpen', true)
->assertSet('selectedDatabaseBackup.uuid', $backup->uuid)
->assertSet('selectedVolumeBackup', null);
});
test('service database backups can be queued from the Livewire component', function () {
Queue::fake();
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->call('backupNow', 'database', $backup->uuid)
->assertDispatched('success', 'Backup queued.');
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job): bool => $job->backup->is($backup));
});
test('service storage backups can be queued from the Livewire component', function () {
Queue::fake();
$volume = LocalPersistentVolume::create([
'name' => 'service-data',
'mount_path' => '/data',
'resource_id' => $this->ownServiceDatabase->id,
'resource_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$backup = $volume->scheduledBackups()->create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->call('backupNow', 'storage', $backup->uuid)
->assertDispatched('success', 'Backup queued.');
Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job): bool => $job->backup->is($backup));
});
test('service backup executions combine database execution history', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$execution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'database_name' => 'coolify',
'size' => 2048,
'finished_at' => now(),
]);
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))
->assertOk()
->assertSee('own-db')
->assertSee('Success')
->assertSee('2 KB');
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))->assertSee("wire:click=\"openExecution('{$execution->uuid}')\"", false);
});
test('service import backup page selects from compatible databases', function () {
$secondDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'analytics-db',
'image' => 'mysql:8',
'custom_type' => 'mysql',
]);
$importUrl = route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]);
$this->get($importUrl)
->assertOk()
->assertSee('Import Backup')
->assertSee('own-db')
->assertSee('analytics-db');
$this->get($importUrl.'/'.$secondDatabase->uuid)
->assertOk()
->assertSee('analytics-db')
->assertSee('Start the database first');
});
test('service import backup redirects when exactly one compatible database exists', function () {
$this->get(route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))->assertRedirectToRoute('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
});
test('service import backup excludes unsupported databases', function () {
$unsupportedDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'cache-db',
'image' => 'redis:7-alpine',
'custom_type' => 'redis',
]);
$this->get(route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))
->assertRedirectToRoute('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$this->get(route('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $unsupportedDatabase->uuid,
]))->assertNotFound();
});
test('service import backup opens the selected compatible database', function () {
$secondDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'analytics-db',
'image' => 'mysql:8',
'custom_type' => 'mysql',
]);
$this->get(route('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $secondDatabase->uuid,
]))
->assertOk()
->assertSee('analytics-db')
->assertSee('Start the database first');
});
test('service import backup requires update authorization for the service and selected database', function () {
$member = User::factory()->create();
$member->teams()->attach($this->teamA, ['role' => 'member']);
$this->actingAs($member);
$parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
];
$this->get(route('project.service.import-backup', $parameters))->assertForbidden();
$this->get(route('project.service.import-backup.database', [
...$parameters,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]))->assertForbidden();
});
test('legacy service database import redirects to the service import page with its database selected', function () {
$legacyUrl = route('project.service.database.import', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$selectedImportUrl = route('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$this->get($legacyUrl)->assertRedirect($selectedImportUrl);
});
test('service storage backups page includes schedules from all compose databases', function () {
$secondDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'analytics-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$backups = collect([$this->ownServiceDatabase, $secondDatabase])->map(function (ServiceDatabase $database) {
return ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'description' => $database->name.' backup',
'frequency' => 'daily',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
]);
});
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))
->assertOk()
->assertSee('>Database</span>', false)
->assertSee('own-db')
->assertSee('analytics-db')
->assertSee("wire:click=\"openSchedule('{$backups->first()->uuid}')\"", false)
->assertSee("wire:click=\"backupNow('database', '{$backups->first()->uuid}')\"", false);
});