mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +00:00
Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react
This commit is contained in:
commit
ee36124300
82 files changed
+8190
-463
No files matched your search
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('app.maintenance.store', 'array');
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'volume-backup-api-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->headers = [
|
||||
'Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$this->volume = LocalPersistentVolume::create([
|
||||
'name' => 'api-volume',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->s3Storage = S3Storage::create([
|
||||
'name' => 'volume-backup-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key',
|
||||
'secret' => 'secret',
|
||||
'bucket' => 'bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->team->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
function createVolumeBackupApiToken($context, User $user, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'volume-backup-api-permission-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $context->team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
it('sets an application volume backup schedule through the API', function () {
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
|
||||
'frequency' => '0 2 * * *',
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
'disable_local_backup' => true,
|
||||
'stop_during_backup' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
'retention_amount_locally' => 3,
|
||||
'retention_days_locally' => 4,
|
||||
'retention_max_storage_locally' => 5.5,
|
||||
'retention_amount_s3' => 6,
|
||||
'retention_days_s3' => 7,
|
||||
'retention_max_storage_s3' => 8.5,
|
||||
'timeout' => 600,
|
||||
]);
|
||||
|
||||
$response->assertCreated()->assertJsonStructure(['uuid', 'message']);
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()->sole();
|
||||
expect($backup->backupable->is($this->volume))->toBeTrue()
|
||||
->and($backup->team_id)->toBe($this->team->id)
|
||||
->and($backup->frequency)->toBe('0 2 * * *')
|
||||
->and($backup->enabled)->toBeTrue()
|
||||
->and($backup->save_s3)->toBeTrue()
|
||||
->and($backup->disable_local_backup)->toBeTrue()
|
||||
->and($backup->stop_during_backup)->toBeTrue()
|
||||
->and($backup->s3_storage_id)->toBe($this->s3Storage->id)
|
||||
->and($backup->retention_amount_locally)->toBe(3)
|
||||
->and($backup->retention_days_locally)->toBe(4)
|
||||
->and($backup->retention_max_storage_locally)->toBe(5.5)
|
||||
->and($backup->retention_amount_s3)->toBe(6)
|
||||
->and($backup->retention_days_s3)->toBe(7)
|
||||
->and($backup->retention_max_storage_s3)->toBe(8.5)
|
||||
->and($backup->timeout)->toBe(600);
|
||||
});
|
||||
|
||||
it('updates the existing backup schedule instead of creating another one', function () {
|
||||
$this->volume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
'disable_local_backup' => true,
|
||||
's3_storage_id' => $this->s3Storage->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
|
||||
'frequency' => 'hourly',
|
||||
'enabled' => false,
|
||||
'save_s3' => false,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()->sole();
|
||||
expect($backup->frequency)->toBe('hourly')
|
||||
->and($backup->enabled)->toBeFalse()
|
||||
->and($backup->save_s3)->toBeFalse()
|
||||
->and($backup->disable_local_backup)->toBeFalse()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('sets a directory backup schedule through the API', function () {
|
||||
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
||||
'uuid' => new_public_id(),
|
||||
'fs_path' => './uploads',
|
||||
'mount_path' => '/app/uploads',
|
||||
'is_directory' => true,
|
||||
'is_host_file' => false,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
])));
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue();
|
||||
});
|
||||
|
||||
it('refuses to delete an application directory before its backup schedule is removed', function () {
|
||||
Process::fake();
|
||||
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
||||
'uuid' => new_public_id(),
|
||||
'fs_path' => './uploads',
|
||||
'mount_path' => '/app/uploads',
|
||||
'is_directory' => true,
|
||||
'is_host_file' => false,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
])));
|
||||
$directory->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}")
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Delete this directory backup schedule and its archives before deleting the directory.');
|
||||
|
||||
expect($directory->fresh())->not->toBeNull();
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('deletes an application volume backup schedule through the API', function () {
|
||||
$backup = $this->volume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups")
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Storage backup schedule and archives deleted.');
|
||||
|
||||
expect($backup->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
it('sets and deletes database and service volume backup schedules through their storage APIs', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'api-postgres',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$databaseVolume = $database->persistentStorages()->firstOrFail();
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'uuid' => new_public_id(),
|
||||
'name' => 'api-service-application',
|
||||
'image' => 'nginx:alpine',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
$serviceVolume = LocalPersistentVolume::create([
|
||||
'name' => 'service-api-volume',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $serviceApplication->id,
|
||||
'resource_type' => $serviceApplication->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups", ['frequency' => 'daily'])
|
||||
->assertCreated();
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups", ['frequency' => 'weekly'])
|
||||
->assertCreated();
|
||||
|
||||
expect($databaseVolume->scheduledBackups()->sole()->frequency)->toBe('daily')
|
||||
->and($serviceVolume->scheduledBackups()->sole()->frequency)->toBe('weekly');
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups")
|
||||
->assertOk();
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups")
|
||||
->assertOk();
|
||||
|
||||
expect($databaseVolume->scheduledBackups()->count())->toBe(0)
|
||||
->and($serviceVolume->scheduledBackups()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects ineligible file storages and invalid schedule settings', function () {
|
||||
$file = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
||||
'uuid' => new_public_id(),
|
||||
'fs_path' => '/tmp/config.json',
|
||||
'mount_path' => '/app/config.json',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
])));
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$file->uuid}/backups", ['frequency' => 'daily'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['storage_uuid']);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
|
||||
'frequency' => 'not-a-frequency',
|
||||
'save_s3' => false,
|
||||
'disable_local_backup' => true,
|
||||
'unexpected' => true,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['frequency', 'disable_local_backup', 'unexpected']);
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an s3 storage owned by another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3Storage = S3Storage::create([
|
||||
'name' => 'other-team-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key',
|
||||
'secret' => 'secret',
|
||||
'bucket' => 'bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3Storage->uuid,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects schedule values larger than the database columns support', function () {
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
|
||||
'frequency' => str_repeat('a', 256),
|
||||
'retention_days_locally' => 2147483648,
|
||||
'retention_days_s3' => 2147483648,
|
||||
'retention_max_storage_locally' => 10000000000,
|
||||
'retention_max_storage_s3' => 10000000000,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'frequency',
|
||||
'retention_days_locally',
|
||||
'retention_days_s3',
|
||||
'retention_max_storage_locally',
|
||||
'retention_max_storage_s3',
|
||||
]);
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('requires authentication, write ability, and an admin team role', function () {
|
||||
$endpoint = "/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups";
|
||||
|
||||
$this->putJson($endpoint, ['frequency' => 'daily'])->assertUnauthorized();
|
||||
|
||||
$readToken = createVolumeBackupApiToken($this, $this->user, ['read']);
|
||||
$this->withToken($readToken)->putJson($endpoint, ['frequency' => 'daily'])->assertForbidden();
|
||||
|
||||
auth()->forgetGuards();
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member, ['role' => 'member']);
|
||||
$memberWriteToken = createVolumeBackupApiToken($this, $member, ['write']);
|
||||
$this->withToken($memberWriteToken)->putJson($endpoint, ['frequency' => 'daily'])->assertForbidden();
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not set schedules through resources owned by another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
$otherApplication = Application::factory()->create([
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherDatabase = StandalonePostgresql::create([
|
||||
'name' => 'other-team-postgres',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherService = Service::factory()->create([
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
foreach (['applications' => $otherApplication, 'databases' => $otherDatabase, 'services' => $otherService] as $type => $resource) {
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/{$type}/{$resource->uuid}/storages/{$this->volume->uuid}/backups", ['frequency' => 'daily'])
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not set a schedule for storage outside the scoped parent', function () {
|
||||
$otherApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherVolume = LocalPersistentVolume::create([
|
||||
'name' => 'other-application-volume',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $otherApplication->id,
|
||||
'resource_type' => $otherApplication->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$otherVolume->uuid}/backups", ['frequency' => 'daily'])
|
||||
->assertNotFound();
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -12,6 +13,7 @@ use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -72,6 +74,196 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('renders a highlighted enable backup button and a regular disable backup button', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
|
||||
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>')
|
||||
->toContain('wire:target="toggleEnabled">Disable Backup</x-forms.button>')
|
||||
->not->toContain('label="Backup Enabled"')
|
||||
->and($s3View)
|
||||
->toContain('wire:target="toggleS3" isHighlighted')
|
||||
->toContain('wire:target="toggleS3">Disable S3</x-forms.button>')
|
||||
->not->toContain('label="S3 Enabled"');
|
||||
});
|
||||
|
||||
it('enables and disables S3 backups from the S3 title action', function () {
|
||||
$s3 = createS3StorageForBackupEditValidationTest($this->team);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => $s3->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'section' => 's3',
|
||||
])
|
||||
->assertSee('Enable S3')
|
||||
->call('toggleS3')
|
||||
->assertSet('saveS3', true)
|
||||
->assertSee('Disable S3');
|
||||
|
||||
expect($backup->refresh()->save_s3)->toBeTruthy();
|
||||
|
||||
$component->call('toggleS3')->assertSet('saveS3', false);
|
||||
|
||||
expect($backup->refresh()->save_s3)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows and saves S3 retention while S3 backups are disabled', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => false,
|
||||
'save_s3' => false,
|
||||
'database_backup_retention_amount_locally' => 0,
|
||||
'database_backup_retention_days_locally' => 0,
|
||||
'database_backup_retention_max_storage_locally' => 0,
|
||||
'database_backup_retention_amount_s3' => 0,
|
||||
'database_backup_retention_days_s3' => 0,
|
||||
'database_backup_retention_max_storage_s3' => 0,
|
||||
'dump_all' => false,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup,
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'section' => 'retention',
|
||||
])
|
||||
->assertSee('S3 Storage Retention')
|
||||
->set('databaseBackupRetentionAmountS3', 12)
|
||||
->set('databaseBackupRetentionDaysS3', 30)
|
||||
->set('databaseBackupRetentionMaxStorageS3', 4.5)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($backup->refresh()->save_s3)->toBeFalsy()
|
||||
->and($backup->database_backup_retention_amount_s3)->toBe(12)
|
||||
->and($backup->database_backup_retention_days_s3)->toBe(30)
|
||||
->and($backup->database_backup_retention_max_storage_s3)->toBe(4.5);
|
||||
});
|
||||
|
||||
it('splits standalone database backup settings and executions across dedicated urls', function () {
|
||||
config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']);
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
$database = $backup->database;
|
||||
$parameters = [
|
||||
'project_uuid' => $database->project()->uuid,
|
||||
'environment_uuid' => $database->environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
];
|
||||
$generalUrl = route('project.database.backup.execution', $parameters);
|
||||
|
||||
$this->get($generalUrl)
|
||||
->assertOk()
|
||||
->assertSee('General')
|
||||
->assertSee('S3')
|
||||
->assertSee('Retention')
|
||||
->assertSee('Executions')
|
||||
->assertSee('Danger Zone')
|
||||
->assertSee('Frequency')
|
||||
->assertDontSee('S3 Enabled')
|
||||
->assertDontSee('Number of backups to keep')
|
||||
->assertDontSee('Cleanup Failed Backups')
|
||||
->assertDontSee('Delete Backups and Schedule');
|
||||
|
||||
$this->get($generalUrl.'/s3')
|
||||
->assertOk()
|
||||
->assertSeeText('No validated S3 available. Configure one here.')
|
||||
->assertDontSee('Disable Local Backup')
|
||||
->assertDontSee('Enable S3')
|
||||
->assertDontSee('Disable S3')
|
||||
->assertDontSee('S3 Storage Retention')
|
||||
->assertDontSee('Local Backup Retention')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
|
||||
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
|
||||
expect(strpos($s3View, '<span>S3 Storage</span>'))
|
||||
->toBeLessThan(strpos($s3View, 'label="Disable Local Backup"'));
|
||||
|
||||
$this->get($generalUrl.'/retention')
|
||||
->assertOk()
|
||||
->assertSee('Local Backup Retention')
|
||||
->assertSee('S3 Storage Retention')
|
||||
->assertSee('Number of backups to keep')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
|
||||
$this->get($generalUrl.'/executions')
|
||||
->assertOk()
|
||||
->assertSee('<h2 class="py-0">Executions</h2>', false)
|
||||
->assertDontSee('Executions <span', false)
|
||||
->assertSee('Cleanup Failed Backups')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Number of backups to keep');
|
||||
|
||||
$this->get($generalUrl.'/danger')
|
||||
->assertOk()
|
||||
->assertSee('Danger Zone')
|
||||
->assertSee('Delete Scheduled Backup')
|
||||
->assertSee('Delete Backups and Schedule')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Number of backups to keep')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
});
|
||||
|
||||
it('enables and disables a scheduled database backup from the title action', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => false,
|
||||
'save_s3' => false,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSet('backupEnabled', false)
|
||||
->assertSee('Enable Backup')
|
||||
->call('toggleEnabled')
|
||||
->assertSet('backupEnabled', true)
|
||||
->assertSee('Disable Backup');
|
||||
|
||||
expect($backup->refresh()->enabled)->toBeTruthy();
|
||||
|
||||
$component->call('toggleEnabled')->assertSet('backupEnabled', false);
|
||||
|
||||
expect($backup->refresh()->enabled)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('redirects to executions after queuing a database backup with unusable S3 storage', function () {
|
||||
Queue::fake();
|
||||
$s3Storage = createS3StorageForBackupEditValidationTest($this->team, 'Unavailable storage');
|
||||
$s3Storage->update(['is_usable' => false]);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => true,
|
||||
's3_storage_id' => $s3Storage->id,
|
||||
'database_backup_retention_amount_locally' => 7,
|
||||
'database_backup_retention_days_locally' => 0,
|
||||
'database_backup_retention_max_storage_locally' => 0,
|
||||
'database_backup_retention_amount_s3' => 7,
|
||||
'database_backup_retention_days_s3' => 0,
|
||||
'database_backup_retention_max_storage_s3' => 0,
|
||||
'dump_all' => false,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
$database = $backup->database;
|
||||
$parameters = [
|
||||
'project_uuid' => $database->project()->uuid,
|
||||
'environment_uuid' => $database->environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
];
|
||||
|
||||
Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup,
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
])
|
||||
->call('backupNow')
|
||||
->assertRedirectToRoute('project.database.backup.executions', $parameters);
|
||||
|
||||
Queue::assertPushed(DatabaseBackupJob::class);
|
||||
});
|
||||
|
||||
it('disables S3 backup when saved without a selected S3 storage', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
|
||||
@@ -162,19 +354,52 @@ it('shows available S3 storages even when S3 backup is disabled', function () {
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
|
||||
->assertSee('First S3')
|
||||
->assertSee('Second S3');
|
||||
});
|
||||
|
||||
it('shows disabled S3 storage dropdown when no storages are available', function () {
|
||||
it('shows only an empty S3 state 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');
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
|
||||
->assertSeeHtml('<h2>S3</h2>')
|
||||
->assertSeeText('No validated S3 available. Configure one here.')
|
||||
->assertSeeHtml('href="'.route('storage.index').'"')
|
||||
->assertSeeHtml('>here</a>')
|
||||
->assertDontSee('Save')
|
||||
->assertDontSee('Enable S3')
|
||||
->assertDontSee('Disable S3')
|
||||
->assertDontSee('S3 Storage')
|
||||
->assertDontSee('Disable Local Backup')
|
||||
->assertDontSee('No S3 storage available');
|
||||
});
|
||||
|
||||
it('allows S3 backups to be disabled when no usable storage remains', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => collect(),
|
||||
'section' => 's3',
|
||||
])
|
||||
->assertSet('saveS3', true)
|
||||
->assertSeeText('No validated S3 available. Configure one here.')
|
||||
->assertDontSee('Disable S3')
|
||||
->call('toggleS3')
|
||||
->assertDispatched('success')
|
||||
->assertSet('saveS3', false)
|
||||
->assertDontSee('Enable S3');
|
||||
|
||||
expect($backup->refresh()->save_s3)->toBeFalsy()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
|
||||
});
|
||||
|
||||
it('shows when S3 backups are currently disabled', function () {
|
||||
@@ -184,7 +409,7 @@ it('shows when S3 backups are currently disabled', function () {
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
|
||||
->assertSee('S3 Storage')
|
||||
->assertSee('(currently disabled)');
|
||||
});
|
||||
@@ -205,3 +430,59 @@ it('saves selected S3 storage immediately when it changes', function () {
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBe($secondS3->id);
|
||||
});
|
||||
|
||||
it('subscribes to database status broadcasts so Backup Now can refresh without a full page reload', function () {
|
||||
$component = app(BackupEdit::class);
|
||||
$method = new ReflectionMethod($component, 'getListeners');
|
||||
$method->setAccessible(true);
|
||||
$listeners = (array) $method->invoke($component);
|
||||
|
||||
expect($listeners)
|
||||
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
|
||||
->toHaveKey('databaseUpdated');
|
||||
});
|
||||
|
||||
it('shows Backup Now after refresh when the database becomes running', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => true,
|
||||
]);
|
||||
$database = $backup->database;
|
||||
$database->update(['status' => 'exited:unhealthy']);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'status' => 'exited:unhealthy',
|
||||
])
|
||||
->assertDontSee('Backup Now')
|
||||
->assertSet('status', 'exited:unhealthy');
|
||||
|
||||
$database->update(['status' => 'running:healthy']);
|
||||
|
||||
$component->call('refreshStatus')
|
||||
->assertSet('status', 'running:healthy')
|
||||
->assertSee('Backup Now');
|
||||
});
|
||||
|
||||
it('hides Backup Now after refresh when the database stops', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => true,
|
||||
]);
|
||||
$database = $backup->database;
|
||||
$database->update(['status' => 'running:healthy']);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'status' => 'running:healthy',
|
||||
])
|
||||
->assertSee('Backup Now')
|
||||
->assertSet('status', 'running:healthy');
|
||||
|
||||
$database->update(['status' => 'exited:unhealthy']);
|
||||
|
||||
$component->call('refreshStatus')
|
||||
->assertSet('status', 'exited:unhealthy')
|
||||
->assertDontSee('Backup Now');
|
||||
});
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -235,6 +237,55 @@ test('deleteOldBackupsLocally identifies correct backups for deletion by retenti
|
||||
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
|
||||
});
|
||||
|
||||
test('database S3 retention remains best effort when deletion fails', function () {
|
||||
$team = Team::factory()->create();
|
||||
$s3 = S3Storage::create([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
'disable_local_backup' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_s3' => 1,
|
||||
]);
|
||||
$newestExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'newest-s3-backup',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/newest.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
]);
|
||||
$oldExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'old-s3-backup',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/old.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::whereKey($newestExecution->id)->update(['created_at' => now()]);
|
||||
ScheduledDatabaseBackupExecution::whereKey($oldExecution->id)->update(['created_at' => now()->subDay()]);
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
|
||||
removeOldBackups($backup);
|
||||
|
||||
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse()
|
||||
->and($backup->executions()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('renders frontend-only application backup search data for volume names and frequencies', function () {
|
||||
$application = createBackupSearchApplication($this->team);
|
||||
$dailyVolume = createBackupSearchVolume($application, 'app-data');
|
||||
$weeklyVolume = createBackupSearchVolume($application, 'Cache-Data');
|
||||
|
||||
$dailyVolume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
$weeklyVolume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => '0 4 * * 0',
|
||||
]);
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $application->project()->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
];
|
||||
|
||||
$this->get(route('project.application.backup.index', [...$parameters, 'search' => 'cache']))
|
||||
->assertOk()
|
||||
->assertSee('Cache-Data')
|
||||
->assertSee('Volume: app-data')
|
||||
->assertSee("search: 'cache'", false)
|
||||
->assertSee('x-model="search"', false)
|
||||
->assertSee('x-show=', false)
|
||||
->assertSee('No scheduled backups match your search.');
|
||||
});
|
||||
|
||||
it('renders frontend-only database backup search data for database names and frequencies', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'Orders-Primary',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$storage = S3Storage::create([
|
||||
'name' => 'Archive-Bucket',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $storage->id,
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => '0 3 * * 1',
|
||||
]);
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
];
|
||||
|
||||
$this->get(route('project.database.backup.index', [...$parameters, 'search' => '0 3']))
|
||||
->assertOk()
|
||||
->assertSee('<h3 class="font-semibold">0 3 * * 1</h3>', false)
|
||||
->assertSee('<h3 class="font-semibold">daily</h3>', false)
|
||||
->assertSee('S3: Archive-Bucket')
|
||||
->assertSee('archive-bucket', false)
|
||||
->assertSee('backup.s3_storage.includes(query)', false)
|
||||
->assertSee('Search by database name, frequency, or S3 storage...')
|
||||
->assertSee('x-model="search"', false)
|
||||
->assertSee('x-show=', false)
|
||||
->assertSee('No scheduled backups match your search.');
|
||||
});
|
||||
|
||||
it('renders unavailable S3 storage only for S3-enabled database backups', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'Orders-Primary',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'weekly',
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
];
|
||||
|
||||
$response = $this->get(route('project.database.backup.index', $parameters))
|
||||
->assertOk()
|
||||
->assertSee('S3: Storage unavailable');
|
||||
|
||||
expect(substr_count($response->getContent(), 'S3:'))->toBe(1);
|
||||
});
|
||||
|
||||
function createBackupSearchApplication(Team $team): Application
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
function createBackupSearchVolume(Application $application, string $name): LocalPersistentVolume
|
||||
{
|
||||
return LocalPersistentVolume::create([
|
||||
'name' => $name,
|
||||
'mount_path' => '/'.str($name)->lower(),
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
use App\Livewire\Project\Database\CreateScheduledBackup;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
@@ -17,126 +18,80 @@ use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createDatabaseForScheduledBackupTest(Team $team): StandalonePostgresql
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'pg-scheduled-backup-validation',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createS3StorageForTeam(Team $team, string $name = 'Test 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->id,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
it('rejects enabling S3 backup without a selected S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', null)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage not owned by the current team', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
$foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3');
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $foreignS3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that is reassigned after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
it('creates a standalone database backup without S3 and opens its configuration', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'postgres',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
->assertDontSee('Save to S3')
|
||||
->set('frequency', 'daily')
|
||||
->call('submit');
|
||||
|
||||
$s3->update(['team_id' => Team::factory()->create()->id]);
|
||||
$backup = ScheduledDatabaseBackup::query()->sole();
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
$component->assertRedirectToRoute('project.database.backup.execution', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
expect($backup->save_s3)->toBeFalsy()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
it('creates a service database backup without S3 and opens its configuration', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
$database = ServiceDatabase::create([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'custom_type' => 'postgresql',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
->assertDontSee('Save to S3')
|
||||
->set('frequency', 'daily')
|
||||
->call('submit');
|
||||
|
||||
$s3->update(['is_usable' => false]);
|
||||
$backup = ScheduledDatabaseBackup::query()->sole();
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
$component->assertRedirectToRoute('project.service.database.backup.show', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
'stack_service_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('creates a scheduled backup with a valid team-owned S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('refreshScheduledBackups');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($s3->id);
|
||||
expect($backup->save_s3)->toBeFalsy()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates a clickhouse backup for its configured database', function () {
|
||||
@@ -154,13 +109,19 @@ it('creates a clickhouse backup for its configured database', function () {
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', 'daily')
|
||||
->call('submit')
|
||||
->assertDispatched('refreshScheduledBackups');
|
||||
->call('submit');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::firstOrFail();
|
||||
|
||||
$component->assertRedirectToRoute('project.database.backup.execution', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
expect($backup->database_type)->toBe(StandaloneClickhouse::class)
|
||||
->and($backup->databases_to_backup)->toBe('analytics');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
it('uses the simplified database backup execution heading and spacing', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">')
|
||||
->toContain('<h2 class="py-0">Executions</h2>')
|
||||
->not->toContain('Executions <span')
|
||||
->toContain('class="flex flex-col gap-4 pt-2"');
|
||||
});
|
||||
|
||||
it('renders scheduled task executions as selectable status cards with expandable logs', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/scheduled-task/executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="flex flex-col gap-2" wire:poll.5000ms="refreshExecutions"')
|
||||
->toContain('<a wire:click="selectTask({{ data_get($execution, \'id\') }})"')
|
||||
->toContain('border-l-2 transition-colors p-4 cursor-pointer')
|
||||
->toContain("'success' => 'Success'")
|
||||
->toContain("'running' => 'In Progress'")
|
||||
->toContain("'failed' => 'Failed'")
|
||||
->toContain("data_get(\$execution, 'id') == \$selectedKey")
|
||||
->toContain('max-h-[600px] overflow-y-auto')
|
||||
->toContain('No executions found.');
|
||||
});
|
||||
@@ -1,14 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Livewire\Project\Service\FileStorage;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
use App\Livewire\Project\Shared\Storages\All;
|
||||
use App\Livewire\Project\Shared\Storages\Show;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -95,7 +100,8 @@ test('livewire file storage stores safe file mounts under the application config
|
||||
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
||||
->set('file_storage_content', 'server {}')
|
||||
->call('submitFileStorage')
|
||||
->assertDispatched('success');
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('configurationChanged');
|
||||
|
||||
$volume = LocalFileVolume::query()->sole();
|
||||
|
||||
@@ -109,7 +115,8 @@ test('livewire host file storage stores an existing host file path without manag
|
||||
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
|
||||
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
|
||||
->call('submitHostFileStorage')
|
||||
->assertDispatched('success');
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('configurationChanged');
|
||||
|
||||
$volume = LocalFileVolume::query()->sole();
|
||||
|
||||
@@ -121,3 +128,80 @@ test('livewire host file storage stores an existing host file path without manag
|
||||
|
||||
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
||||
});
|
||||
|
||||
test('livewire volume storage refreshes the storage list and configuration warning', function () {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('name', 'data')
|
||||
->set('mount_path', '/app/data')
|
||||
->call('submitPersistentVolume')
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('refreshStorages')
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
|
||||
test('volume storage list shows volumes added after it was mounted', function () {
|
||||
$firstVolume = LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-first',
|
||||
'mount_path' => '/app/first',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$storageList = Livewire::test(All::class, ['resource' => $this->application])
|
||||
->assertSee($firstVolume->name);
|
||||
|
||||
$secondVolume = LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-second',
|
||||
'mount_path' => '/app/second',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$storageList
|
||||
->assertDontSee($secondVolume->name)
|
||||
->dispatch('refreshStorages')
|
||||
->assertSee($secondVolume->name);
|
||||
});
|
||||
|
||||
test('deleting a file mount refreshes the configuration warning', function () {
|
||||
$file = LocalFileVolume::create([
|
||||
'fs_path' => '/etc/nginx/nginx.conf',
|
||||
'mount_path' => '/etc/nginx/nginx.conf',
|
||||
'is_host_file' => true,
|
||||
'is_based_on_git' => false,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(FileStorage::class, ['fileStorage' => $file])
|
||||
->call('delete', 'password')
|
||||
->assertDispatched('configurationChanged');
|
||||
|
||||
expect($file->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
test('deleting a volume mount refreshes the configuration warning', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'name' => $database->uuid.'-data',
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['storage' => $volume, 'resource' => $database])
|
||||
->call('delete', 'password')
|
||||
->assertDispatched('configurationChanged');
|
||||
|
||||
expect($volume->fresh())->toBeNull();
|
||||
});
|
||||
@@ -7,6 +7,7 @@ use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -238,6 +239,57 @@ test('failed method updates status when backup was not successful', function ()
|
||||
expect($log->message)->toContain('Some real failure');
|
||||
});
|
||||
|
||||
test('retention cleanup failure does not fail a successful database backup', function () {
|
||||
$team = Team::factory()->create();
|
||||
$s3 = S3Storage::create([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
'disable_local_backup' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_s3' => 1,
|
||||
]);
|
||||
$oldExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'old-backup',
|
||||
'database_name' => 'database',
|
||||
'filename' => '/backup/old.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
'local_storage_deleted' => true,
|
||||
'created_at' => now()->subDay(),
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'new-backup',
|
||||
'database_name' => 'database',
|
||||
'filename' => '/backup/new.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
'local_storage_deleted' => true,
|
||||
]);
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
expect(fn () => (new ReflectionClass($job))->getMethod('removeExpiredBackups')->invoke($job))
|
||||
->not->toThrow(Throwable::class);
|
||||
|
||||
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse();
|
||||
});
|
||||
|
||||
test('s3 storage has scheduled backups relationship', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -87,6 +88,30 @@ it('refreshes configuration changes when the event is received', function () {
|
||||
->assertSee('Build command');
|
||||
});
|
||||
|
||||
it('shows an unapplied configuration warning after a directory mount is added', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
|
||||
LocalFileVolume::withoutEvents(fn () => LocalFileVolume::forceCreate([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/data',
|
||||
'mount_path' => '/app/data',
|
||||
'is_directory' => true,
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]));
|
||||
|
||||
$component
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Directory mount')
|
||||
->assertSee('Please redeploy to apply the new configuration.');
|
||||
});
|
||||
|
||||
it('refreshes stale modal configuration diff before opening changes', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
@@ -131,6 +131,19 @@ function mobileActionsAreBeforeSelect(string $heading, string $actionsId, string
|
||||
return $actionsPosition !== false && $selectPosition !== false && $actionsPosition < $selectPosition;
|
||||
}
|
||||
|
||||
it('places database backups immediately after configuration in navigation menus', function () {
|
||||
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
$mobileMenuItems = str($databaseHeading)->between('$databasePageItems = [', '$databaseConfigurationItems = [')->toString();
|
||||
$desktopMenuItems = str($databaseHeading)->between('class="scrollbar hidden', '</nav>')->toString();
|
||||
|
||||
foreach ([$mobileMenuItems, $desktopMenuItems] as $menuItems) {
|
||||
expect(strpos($menuItems, 'Configuration'))
|
||||
->toBeLessThan(strpos($menuItems, 'Backups'))
|
||||
->and(strpos($menuItems, 'Backups'))->toBeLessThan(strpos($menuItems, 'Logs'))
|
||||
->and(strpos($menuItems, 'Logs'))->toBeLessThan(strpos($menuItems, 'Terminal'));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps configuration sidebars hidden until desktop breakpoint', function () {
|
||||
expect(file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')))
|
||||
->toContain('sub-menu-wrapper hidden md:flex');
|
||||
|
||||
@@ -28,3 +28,35 @@ it('keeps mutable Livewire components behind authorization checks', function (st
|
||||
['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
|
||||
],
|
||||
]);
|
||||
|
||||
it('authorizes every volume backup form control', function (string $path, array $controlPatterns) {
|
||||
$source = file_get_contents(base_path($path));
|
||||
|
||||
foreach ($controlPatterns as $controlPattern) {
|
||||
expect($source)->toMatch($controlPattern);
|
||||
}
|
||||
})->with([
|
||||
'retention controls' => [
|
||||
'resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php',
|
||||
[
|
||||
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionAmountLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionDaysLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionAmountS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionDaysS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
],
|
||||
],
|
||||
'S3 controls' => [
|
||||
'resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php',
|
||||
[
|
||||
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
|
||||
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Enable S3<\/x-forms\.button>/s',
|
||||
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Disable S3<\/x-forms\.button>/s',
|
||||
'/<x-forms\.select(?=[^>]*id="s3StorageId")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>/s',
|
||||
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*instantSave)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*disabled)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -5,6 +5,7 @@ use App\Livewire\Project\Service\Heading;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -139,3 +140,65 @@ test('owner can still hydrate service heading with own service', function () {
|
||||
])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('service database backup schedules use dedicated general retention and executions urls', 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;
|
||||
|
||||
$this->get($listUrl)
|
||||
->assertOk()
|
||||
->assertSee('href="'.$generalUrl.'"', false);
|
||||
|
||||
$this->get($generalUrl)
|
||||
->assertOk()
|
||||
->assertSee('Frequency')
|
||||
->assertDontSee('S3 Enabled')
|
||||
->assertDontSee('Number of backups to keep')
|
||||
->assertDontSee('Cleanup Failed Backups')
|
||||
->assertDontSee('Delete Backups and Schedule');
|
||||
|
||||
$this->get($generalUrl.'/s3')
|
||||
->assertOk()
|
||||
->assertSee('S3 Storage')
|
||||
->assertDontSee('S3 Storage Retention')
|
||||
->assertDontSee('Local Backup Retention')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
|
||||
$this->get($generalUrl.'/retention')
|
||||
->assertOk()
|
||||
->assertSee('Local Backup Retention')
|
||||
->assertSee('S3 Storage Retention')
|
||||
->assertSee('Number of backups to keep')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
|
||||
$this->get($generalUrl.'/executions')
|
||||
->assertOk()
|
||||
->assertSee('<h2 class="py-0">Executions</h2>', false)
|
||||
->assertDontSee('Executions <span', false)
|
||||
->assertSee('Cleanup Failed Backups')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Number of backups to keep');
|
||||
|
||||
$this->get($generalUrl.'/danger')
|
||||
->assertOk()
|
||||
->assertSee('Danger Zone')
|
||||
->assertSee('Delete Scheduled Backup')
|
||||
->assertSee('Delete Backups and Schedule')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Number of backups to keep')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
});
|
||||
@@ -59,23 +59,27 @@ it('uses a shared refresh badge for resource status refresh actions', function (
|
||||
->not->toContain('<svg');
|
||||
});
|
||||
|
||||
it('renders health warning helpers as badges instead of warning icons', function () {
|
||||
it('renders health warning helpers without increasing the badge row height', function (string $status, string $expectedText) {
|
||||
$html = view('components.status.running', [
|
||||
'status' => 'running:unknown',
|
||||
'status' => $status,
|
||||
])->render();
|
||||
$runningStatus = file_get_contents(resource_path('views/components/status/running.blade.php'));
|
||||
|
||||
expect($html)
|
||||
->toContain('No health check')
|
||||
->toContain($expectedText)
|
||||
->toContain('class="flex items-center gap-1 leading-none"')
|
||||
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
|
||||
->not->toContain('<svg');
|
||||
|
||||
expect($runningStatus)
|
||||
->toContain('<x-status-badge')
|
||||
->toContain('class="flex items-center gap-1"')
|
||||
->toContain('class="flex items-center gap-1 leading-none"')
|
||||
->not->toContain('class="px-2"')
|
||||
->not->toContain('viewBox="0 0 256 256"');
|
||||
});
|
||||
})->with([
|
||||
'unknown health' => ['running:unknown', 'No health check'],
|
||||
'unhealthy' => ['running:unhealthy', 'Unhealthy'],
|
||||
]);
|
||||
|
||||
it('renders restart counts as warning badges', function () {
|
||||
$resource = new class
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
function backupDownloadServer(): Server
|
||||
{
|
||||
$server = new Server;
|
||||
$server->forceFill([
|
||||
'ip' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'user' => 'root',
|
||||
]);
|
||||
|
||||
$privateKey = Mockery::mock(PrivateKey::class);
|
||||
$privateKey->shouldReceive('getKeyLocation')->andReturn('/tmp/private-key');
|
||||
$server->setRelation('privateKey', $privateKey);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('throws when the backup file does not exist on the server', function () {
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturnFalse();
|
||||
|
||||
Storage::shouldReceive('build')
|
||||
->once()
|
||||
->with([
|
||||
'driver' => 'sftp',
|
||||
'host' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'username' => 'root',
|
||||
'privateKey' => '/tmp/private-key',
|
||||
'root' => '/',
|
||||
])
|
||||
->andReturn($disk);
|
||||
|
||||
streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
})->throws(FileNotFoundException::class);
|
||||
|
||||
it('streams a backup file with the requested content type', function () {
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
fwrite($stream, 'backup contents');
|
||||
rewind($stream);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')->once()->andReturnTrue();
|
||||
$disk->shouldReceive('readStream')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturn($stream);
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
|
||||
$response = streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class)
|
||||
->and($response->headers->get('content-type'))->toBe('application/gzip')
|
||||
->and($response->headers->get('content-disposition'))->toBe('attachment; filename="archive.tar.gz"');
|
||||
|
||||
ob_start();
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$contents = ob_get_clean();
|
||||
|
||||
expect($contents)->toBe('backup contents');
|
||||
});
|
||||
@@ -4,6 +4,8 @@ use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
@@ -79,6 +81,35 @@ it('detects redeploy-only domain changes', function () {
|
||||
->and($change['new_full_value'])->toBe($domains);
|
||||
});
|
||||
|
||||
it('detects added storage mounts as redeploy-only changes', function (string $type) {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
if ($type === 'volume') {
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $application->uuid.'-data',
|
||||
'mount_path' => '/app/data',
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]);
|
||||
} else {
|
||||
LocalFileVolume::withoutEvents(fn () => LocalFileVolume::forceCreate([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/data',
|
||||
'mount_path' => '/app/data',
|
||||
'is_directory' => $type === 'directory',
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]));
|
||||
}
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->requiresBuild())->toBeFalse()
|
||||
->and(collect($diff->changes())->pluck('section'))->toContain('storage');
|
||||
})->with(['volume', 'directory', 'file']);
|
||||
|
||||
it('detects Docker image reference changes as redeploy-only changes', function (string $field, string $label, string $newValue) {
|
||||
$application = snapshotTestApplication([
|
||||
'build_pack' => 'dockerimage',
|
||||
|
||||
Reference in new issue
Block a user