Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai committed 2026-07-20 12:09:17 +02:00
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);
});
+286 -5
View File
@@ -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');
+178
View File
@@ -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.');
});
+86 -2
View File
@@ -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);
+13
View File
@@ -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');
});
+9 -5
View File
@@ -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
+72
View File
@@ -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',