feat: add backup controls, resource search, and Sentinel enablement

Unify service backup history with pagination, schedule settings actions, S3 destination details, and team-safe access checks. Display server built-ins as read-only variables and add loading and empty states for searchable resources.
This commit is contained in:
Andras Bacsai
2026-09-07 16:46:37 +02:00
parent 183e28682c
commit b123356acd
24 changed files with 1110 additions and 116 deletions
+32 -6
View File
@@ -247,6 +247,7 @@ it('redirects to executions after queuing a database backup with unusable S3 sto
'timeout' => 3600,
]);
$database = $backup->database;
$database->update(['status' => 'running:healthy']);
$parameters = [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
@@ -509,7 +510,7 @@ it('subscribes to database status broadcasts so Backup Now can refresh without a
->toHaveKey('databaseUpdated');
});
it('shows Backup Now after refresh when the database becomes running', function () {
it('enables Back up now after refresh when the database becomes running', function () {
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => true,
]);
@@ -521,17 +522,21 @@ it('shows Backup Now after refresh when the database becomes running', function
'availableS3Storages' => $this->team->s3s,
'status' => 'exited:unhealthy',
])
->assertDontSee('Backup Now')
->assertSee('Back up now')
->assertSet('status', 'exited:unhealthy');
expect($component->html())->toMatch('/<button\s+disabled[^>]*wire:click="backupNow"/s');
$database->update(['status' => 'running:healthy']);
$component->call('refreshStatus')
->assertSet('status', 'running:healthy')
->assertSee('Backup Now');
->assertSee('Back up now');
expect($component->html())->not->toMatch('/<button\s+disabled[^>]*wire:click="backupNow"/s');
});
it('hides Backup Now after refresh when the database stops', function () {
it('disables Back up now after refresh when the database stops', function () {
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => true,
]);
@@ -543,12 +548,33 @@ it('hides Backup Now after refresh when the database stops', function () {
'availableS3Storages' => $this->team->s3s,
'status' => 'running:healthy',
])
->assertSee('Backup Now')
->assertSee('Back up now')
->assertSet('status', 'running:healthy');
$database->update(['status' => 'exited:unhealthy']);
$component->call('refreshStatus')
->assertSet('status', 'exited:unhealthy')
->assertDontSee('Backup Now');
->assertSee('Back up now');
expect($component->html())->toMatch('/<button\s+disabled[^>]*wire:click="backupNow"/s');
});
it('renders S3 backup selectors outside the scrollable modal', function () {
createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team);
$html = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
'section' => 's3',
])->html();
$dom = new DOMDocument;
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
foreach (['s3StorageId-panel', 'disableLocalBackup-panel'] as $panelId) {
$panels = $xpath->query('//template[@x-teleport="body"]/div[@id="'.$panelId.'"]');
expect($panels->length)->toBe(1);
expect($panels->item(0)->getAttribute('style'))->toContain('position: fixed', 'z-index: 9999');
}
});
+132
View File
@@ -0,0 +1,132 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Livewire\Project\Database\BackupEdit;
use App\Livewire\Project\Database\BackupNow;
use App\Livewire\Project\Service\VolumeBackup\Index;
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\ServiceDatabase;
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\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$team = Team::factory()->create();
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$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]);
$resourceAttributes = [
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
$this->service = Service::factory()->create(['server_id' => $server->id, ...$resourceAttributes]);
$this->databases = [
'standalone' => StandalonePostgresql::create([
...$resourceAttributes,
'name' => 'postgres',
'postgres_password' => 'password',
]),
'service' => ServiceDatabase::create([
'service_id' => $this->service->id,
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]),
];
$this->backups = collect($this->databases)->map(fn ($database) => ScheduledDatabaseBackup::create([
'team_id' => $team->id,
'frequency' => 'daily',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
]));
Queue::fake();
});
dataset('database backup controls', [
'standalone settings' => [BackupEdit::class, 'standalone'],
'service settings' => [BackupEdit::class, 'service'],
'standalone button' => [BackupNow::class, 'standalone'],
'service button' => [BackupNow::class, 'service'],
'service list' => [Index::class, 'service'],
]);
it('only enables and queues database backups for running targets', function (string $componentClass, string $type, string $status, bool $running) {
$database = $this->databases[$type];
$database->update(['status' => $status]);
$backup = $this->backups[$type]->fresh();
$parameters = $componentClass === Index::class
? ['service' => $this->service]
: ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])];
$component = Livewire::test($componentClass, $parameters);
$dom = new DOMDocument;
@$dom->loadHTML($component->html());
$buttons = (new DOMXPath($dom))->query('//button');
$backupButtons = [];
foreach ($buttons as $button) {
if (str_starts_with($button->getAttribute('wire:click'), 'backupNow') || str_starts_with($button->getAttribute('wire:click.stop'), 'backupNow')) {
$backupButtons[] = $button;
}
}
expect($backupButtons)->toHaveCount(1);
expect($backupButtons[0]->hasAttribute('disabled'))->toBe(! $running);
$component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : []));
if ($running) {
Queue::assertPushed(DatabaseBackupJob::class);
} else {
$component->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect();
Queue::assertNotPushed(DatabaseBackupJob::class);
}
})->with('database backup controls')->with([
['running:healthy', true],
['running:unhealthy', true],
['exited:unhealthy', false],
['restarting:unhealthy', false],
]);
it('checks current database status before queuing from a stale page', function (string $componentClass, string $type) {
$database = $this->databases[$type];
$database->update(['status' => 'running:healthy']);
$backup = $this->backups[$type]->fresh();
$parameters = $componentClass === Index::class
? ['service' => $this->service]
: ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])];
$component = Livewire::test($componentClass, $parameters);
$database->update(['status' => 'exited:unhealthy']);
$component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : []))
->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect();
Queue::assertNotPushed(DatabaseBackupJob::class);
})->with('database backup controls');
it('refreshes service backup buttons when the service status check completes', function () {
$database = $this->databases['service'];
$database->update(['status' => 'exited:unhealthy']);
$component = Livewire::test(Index::class, ['service' => $this->service]);
$database->update(['status' => 'running:healthy']);
$component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked');
expect($component->html())->not->toMatch('/<button\s+disabled[^>]*wire:click.stop="backupNow/s');
$database->update(['status' => 'exited:unhealthy']);
$component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked');
expect($component->html())->toMatch('/<button\s+disabled[^>]*wire:click.stop="backupNow/s');
});
@@ -82,11 +82,10 @@ it('creates a service database backup without S3 and opens its configuration', f
$backup = ScheduledDatabaseBackup::query()->sole();
$component->assertRedirectToRoute('project.service.database.backup.show', [
$component->assertRedirectToRoute('project.service.volume-backups.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
]);
@@ -114,14 +113,22 @@ it('selects a service database when creating a backup from the unified backups p
'custom_type' => 'postgresql',
]);
Livewire::test(CreateScheduledBackup::class, ['service' => $service])
$component = Livewire::test(CreateScheduledBackup::class, ['service' => $service])
->assertSee('Database')
->assertSee('analytics')
->set('selectedDatabaseUuid', $analytics->uuid)
->set('frequency', 'daily')
->call('submit');
expect(ScheduledDatabaseBackup::query()->sole()->database->is($analytics))->toBeTrue();
$backup = ScheduledDatabaseBackup::query()->sole();
expect($backup->database->is($analytics))->toBeTrue();
$component->assertRedirectToRoute('project.service.volume-backups.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $service->uuid,
'backup_uuid' => $backup->uuid,
]);
});
it('creates a clickhouse backup for its configured database', function () {
+127
View File
@@ -0,0 +1,127 @@
<?php
use App\Actions\Server\StartSentinel;
use App\Livewire\Project\Shared\GetLogs;
use App\Livewire\Server\Sentinel\Logs;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
$this->actingAs($user);
$this->server = Server::factory()->create(['team_id' => $team->id]);
});
it('does not show sync status or fetch logs when sentinel is disabled', function (bool $recentHeartbeat) {
$this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat);
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(Logs::class)
->assertSee('Sentinel is disabled')
->assertSeeHtml('wire:click="enableSentinel"')
->assertDontSee('Out of sync')
->assertDontSee('In sync')
->assertDontSeeLivewire(GetLogs::class);
})->with([false, true]);
it('shows sync status and logs when sentinel is enabled', function (bool $metricsOnly, bool $recentHeartbeat) {
$this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat);
$this->server->settings()->update([
'is_sentinel_enabled' => ! $metricsOnly,
'is_metrics_enabled' => $metricsOnly,
'is_build_server' => false,
]);
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(Logs::class)
->assertDontSee('Sentinel is disabled')
->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync')
->assertSeeLivewire(GetLogs::class);
})->with([false, true])->with([false, true]);
it('enables sentinel from the logs page', function () {
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
StartSentinel::shouldRun()->once()->withArgs(function (Server $server, bool $restart): bool {
expect($server->id)->toBe($this->server->id);
expect($restart)->toBeTrue();
$server->settings->update(['is_sentinel_enabled' => true]);
return true;
});
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(Logs::class)
->call('enableSentinel')
->assertDontSee('Sentinel is disabled')
->assertDontSee('Enable Sentinel')
->assertSeeLivewire(GetLogs::class)
->assertDispatched('refreshServerShow')
->assertDispatched('success');
expect($this->server->fresh()->isSentinelEnabled())->toBeTrue();
});
it('keeps sentinel disabled when startup fails', function () {
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
StartSentinel::shouldRun()->once()->andThrow(new RuntimeException('Startup failed'));
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(Logs::class)
->call('enableSentinel')
->assertSee('Sentinel is disabled')
->assertDontSeeLivewire(GetLogs::class)
->assertDispatched('error')
->assertNotDispatched('success');
expect($this->server->fresh()->isSentinelEnabled())->toBeFalse();
});
it('does not enable sentinel on unsupported servers', function (string $setting) {
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false, $setting => true]);
StartSentinel::shouldRun()->never();
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(Logs::class)
->call('enableSentinel')
->assertSee('Sentinel is disabled')
->assertDispatched('error');
})->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']);
it('denies enabling sentinel to members and users outside the server team', function (bool $crossTeam) {
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
$user = User::factory()->create();
if (! $crossTeam) {
$this->server->team->members()->attach($user->id, ['role' => 'member']);
}
$this->actingAs($user);
StartSentinel::shouldRun()->never();
$component = new Logs;
$component->server = $this->server->fresh();
expect(fn () => $component->enableSentinel())
->toThrow(AuthorizationException::class);
expect($this->server->fresh()->isSentinelEnabled())->toBeFalse();
})->with([false, true]);
it('does not restart sentinel when it is already enabled', function () {
$this->server->settings()->update(['is_sentinel_enabled' => true, 'is_build_server' => false]);
StartSentinel::shouldRun()->never();
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(Logs::class)
->assertSeeLivewire(GetLogs::class)
->call('enableSentinel')
->assertNotDispatched('success');
});
@@ -0,0 +1,141 @@
<?php
use App\Livewire\Server\Resources;
use App\Models\Server;
beforeEach(function () {
$this->component = new Resources;
$this->component->server = Mockery::mock(Server::class)->makePartial();
});
it('paginates sorted resources on both tabs', function (string $tab, string $nameKey) {
$rows = collect(range(23, 1))->map(fn (int $id) => [$nameKey => "Resource $id"]);
$this->component->activeTab = $tab;
if ($tab === 'managed') {
$this->component->server->shouldReceive('definedResources')->andReturn($rows);
} else {
$this->component->unmanagedContainers = $rows->all();
}
$page = $this->component->render()->getData()['resources'];
expect($page->total())->toBe(23)
->and($page->count())->toBe(10)
->and($page->pluck($nameKey)->all())->toBe(array_map(fn ($id) => "Resource $id", range(1, 10)));
$this->component->nextPage();
$page = $this->component->render()->getData()['resources'];
expect($page->currentPage())->toBe(2)->and($page->count())->toBe(10)
->and($page->first()[$nameKey])->toBe('Resource 11');
$this->component->nextPage();
$page = $this->component->render()->getData()['resources'];
expect($page->count())->toBe(3)->and($page->first()[$nameKey])->toBe('Resource 21');
$this->component->previousPage();
expect($this->component->render()->getData()['resources']->currentPage())->toBe(2);
})->with([['managed', 'name'], ['unmanaged', 'Names']]);
it('resets pagination and search when switching tabs but preserves them on refresh', function () {
$this->component->server->shouldReceive('refresh')->andReturnSelf();
$this->component->server->shouldReceive('loadUnmanagedContainers')->andReturn(collect());
$this->component->setPage(3);
$this->component->search = 'managed resource';
$this->component->loadUnmanagedContainers();
expect($this->component->getPage())->toBe(1)
->and($this->component->search)->toBe('');
$this->component->setPage(2);
$this->component->search = 'container';
$this->component->loadUnmanagedContainers();
expect($this->component->getPage())->toBe(2)
->and($this->component->search)->toBe('container');
$this->component->loadManagedContainers();
expect($this->component->getPage())->toBe(1)
->and($this->component->search)->toBe('');
$this->component->setPage(2);
$this->component->search = 'application';
$this->component->loadManagedContainers();
expect($this->component->getPage())->toBe(2)
->and($this->component->search)->toBe('application');
});
it('clamps page size and resets the page', function (int $size, int $expected) {
$this->component->setPage(3);
$this->component->perPage = $size;
$this->component->updatedPerPage();
expect($this->component->perPage)->toBe($expected)
->and($this->component->getPage())->toBe(1);
})->with([[25, 25], [0, 1], [200, 100]]);
it('clamps stale pages after resources disappear including an empty list', function (int $count, int $expectedPage) {
$this->component->activeTab = 'unmanaged';
$this->component->unmanagedContainers = array_fill(0, $count, ['Names' => 'Container']);
$this->component->setPage(9);
$page = $this->component->render()->getData()['resources'];
expect($page->currentPage())->toBe($expectedPage)
->and($this->component->getPage())->toBe($expectedPage)
->and($page->total())->toBe($count);
})->with([[12, 2], [0, 1]]);
it('renders shared pagination for both resource tabs with stable row identities', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
expect($view)->toContain('<x-table-pagination')
->toContain('<x-page-size-select')
->toContain('previous-action="previousPage"')
->toContain('next-action="nextPage"')
->toContain('wire:key="managed-')
->toContain('wire:key="unmanaged-')
->not->toContain('$server->definedResources()');
});
it('searches names across all pages before pagination on both tabs', function (string $tab, string $nameKey) {
$rows = collect(range(1, 25))->map(fn (int $id) => [$nameKey => "Resource $id"]);
$this->component->activeTab = $tab;
if ($tab === 'managed') {
$this->component->server->shouldReceive('definedResources')->andReturn($rows);
} else {
$this->component->unmanagedContainers = $rows->all();
}
$this->component->setPage(3);
$this->component->search = ' RESOURCE 2 ';
$this->component->updatedSearch();
$page = $this->component->render()->getData()['resources'];
expect($page->currentPage())->toBe(1)
->and($page->total())->toBe(7)
->and($page->pluck($nameKey)->all())->toBe([
'Resource 2', 'Resource 20', 'Resource 21', 'Resource 22',
'Resource 23', 'Resource 24', 'Resource 25',
]);
$this->component->search = 'missing';
$this->component->updatedSearch();
expect($this->component->render()->getData()['resources']->total())->toBe(0);
$this->component->search = '';
$this->component->updatedSearch();
$page = $this->component->render()->getData()['resources'];
expect($page->total())->toBe(25)->and($page->currentPage())->toBe(1);
$this->component->search = ' ';
expect($this->component->render()->getData()['resources']->total())->toBe(25);
})->with([['managed', 'name'], ['unmanaged', 'Names']]);
it('provides accessible live search and a distinct no-results message', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
expect($view)->toContain('wire:model.live.debounce.300ms="search"')
->toContain('aria-label="Search resources by name"')
->toContain('aria-label="Clear search"')
->toContain('No matching resources')
->toContain('No matching containers');
});
it('shows search feedback and prevents interaction with stale results while searching', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
expect($view)
->toContain('<x-table.loading target="search" text="Searching resources..." />')
->not->toContain('wire:loading.inline-flex wire:target="search"')
->toContain('wire:loading.class="pointer-events-none opacity-40 blur-[2px]"')
->toContain('wire:loading.attr="inert" wire:target="search"');
});
+295 -1
View File
@@ -3,14 +3,17 @@
use App\Jobs\DatabaseBackupJob;
use App\Jobs\VolumeBackupJob;
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\BackupExecutions;
use App\Livewire\Project\Service\Heading;
use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -20,6 +23,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
@@ -234,6 +238,7 @@ test('service database backup schedules open in the Livewire component', functio
test('service database backups can be queued from the Livewire component', function () {
Queue::fake();
$this->ownServiceDatabase->update(['status' => 'running:healthy']);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
@@ -448,5 +453,294 @@ test('service storage backups page includes schedules from all compose databases
->assertSee('own-db')
->assertSee('analytics-db')
->assertSee("wire:click=\"openSchedule('{$backups->first()->uuid}')\"", false)
->assertSee("wire:click=\"backupNow('database', '{$backups->first()->uuid}')\"", false);
->assertSee("wire:click.stop=\"backupNow('database', '{$backups->first()->uuid}')\"", false);
});
test('service backup settings open automatically from the creation redirect', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
Livewire::withQueryParams(['backup_uuid' => $backup->uuid])
->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->assertSet('scheduleModalOpen', true)
->assertSet('selectedDatabaseBackup.uuid', $backup->uuid)
->assertSee('S3');
});
test('service backup settings reject a backup belonging to another team', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamB->id,
'frequency' => 'daily',
'database_id' => $this->otherServiceDatabase->id,
'database_type' => $this->otherServiceDatabase->getMorphClass(),
]);
$this->expectException(ModelNotFoundException::class);
Livewire::withQueryParams(['backup_uuid' => $backup->uuid])
->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]);
});
test('service backups have explicit settings actions for database and storage schedules', function () {
$databaseBackup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$volume = LocalPersistentVolume::create([
'name' => 'service-data',
'mount_path' => '/data',
'resource_id' => $this->ownServiceDatabase->id,
'resource_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$volumeBackup = $volume->scheduledBackups()->create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
]);
$html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html();
$dom = new DOMDocument;
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
$buttons = $xpath->query('//button[contains(., "Settings")]');
$actions = [];
foreach ($buttons as $button) {
$actions[] = $button->getAttribute('wire:click.stop');
}
expect($actions)->toContain("openSchedule('{$databaseBackup->uuid}')", "openSchedule('{$volumeBackup->uuid}')");
foreach (['database' => $databaseBackup, 'storage' => $volumeBackup] as $type => $backup) {
$backupAction = "wire:click.stop=\"backupNow('{$type}', '{$backup->uuid}')\"";
$settingsAction = "wire:click.stop=\"openSchedule('{$backup->uuid}')\"";
expect(strpos($html, $backupAction))->toBeLessThan(strpos($html, $settingsAction));
}
});
test('members cannot open service backup settings', function () {
$this->userA->teams()->updateExistingPivot($this->teamA->id, ['role' => 'member']);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
Livewire::withQueryParams(['backup_uuid' => $backup->uuid])
->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->assertForbidden();
});
test('closing service backup settings clears the backup query parameter', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$component = Livewire::withQueryParams(['backup_uuid' => $backup->uuid, 'search' => 'own-db'])
->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->assertSet('scheduleModalOpen', true)
->assertSet('backupUuid', $backup->uuid);
expect($component->effects['url']['backupUuid'])
->toMatchArray(['as' => 'backup_uuid', 'use' => 'replace', 'except' => '']);
$component->dispatch('modalClosed')
->assertSet('scheduleModalOpen', false)
->assertSet('selectedDatabaseBackup', null)
->assertSet('selectedVolumeBackup', null)
->assertSet('backupUuid', '')
->assertSet('search', 'own-db')
->assertNoRedirect();
});
test('service execution history paginates both backup types without truncating older runs', function () {
$schedule = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$databaseExecutions = collect(range(1, 105))->map(fn ($index) => ScheduledDatabaseBackupExecution::forceCreate([
'scheduled_database_backup_id' => $schedule->id,
'status' => 'success',
'created_at' => now()->subMinutes($index),
]));
$volume = LocalPersistentVolume::create([
'name' => 'service-data',
'mount_path' => '/data',
'resource_id' => $this->ownServiceDatabase->id,
'resource_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamA->id, 'frequency' => 'daily']);
$volumeExecution = ScheduledVolumeBackupExecution::create([
'scheduled_volume_backup_id' => $volumeSchedule->id,
'status' => 'success',
]);
$component = Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertViewHas('executions', function ($executions) use ($volumeExecution, $databaseExecutions) {
expect($executions)->toBeInstanceOf(LengthAwarePaginator::class)
->and($executions->total())->toBe(106)
->and($executions->count())->toBe(10)
->and($executions->first()['uuid'])->toBe($volumeExecution->uuid)
->and($executions->last()['uuid'])->toBe($databaseExecutions[8]->uuid);
return true;
})
->assertSeeHtml('aria-label="Next page"');
$component->call('openExecution', $volumeExecution->uuid)
->assertSet('selectedExecution.uuid', $volumeExecution->uuid)
->call('closeExecutionModal');
$component->call('nextPage', 'executionsPage')
->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 2 && $executions->first()['uuid'] === $databaseExecutions[9]->uuid);
$component->call('setPage', 11, 'executionsPage')
->assertViewHas('executions', fn ($executions) => $executions->count() === 6 && $executions->last()['uuid'] === $databaseExecutions->last()->uuid)
->call('openExecution', $databaseExecutions->last()->uuid)
->assertSet('executionModalOpen', true)
->assertSet('selectedExecution.uuid', $databaseExecutions->last()->uuid);
$component->set('perPage', 25)
->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 1 && $executions->count() === 25);
$component->call('setPage', 999, 'executionsPage')
->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 5 && $executions->count() === 6);
$component->set('perPage', 1000)->assertSet('perPage', 100);
$component->set('perPage', 0)->assertSet('perPage', 1);
});
test('service execution pagination excludes other teams and denies opening their runs', function (string $type) {
$schedule = ScheduledDatabaseBackup::create([
'team_id' => $this->teamB->id,
'frequency' => 'daily',
'database_id' => $this->otherServiceDatabase->id,
'database_type' => $this->otherServiceDatabase->getMorphClass(),
]);
$execution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $schedule->id,
'status' => 'success',
]);
if ($type === 'storage') {
$volume = LocalPersistentVolume::create([
'name' => 'other-service-data',
'mount_path' => '/data',
'resource_id' => $this->otherServiceDatabase->id,
'resource_type' => $this->otherServiceDatabase->getMorphClass(),
]);
$volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamB->id, 'frequency' => 'daily']);
$execution = ScheduledVolumeBackupExecution::create([
'scheduled_volume_backup_id' => $volumeSchedule->id,
'status' => 'success',
]);
}
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertViewHas('executions', fn ($executions) => $executions->isEmpty())
->assertDontSeeHtml('aria-label="Next page"')
->call('openExecution', $execution->uuid)
->assertNotFound();
})->with(['database', 'storage']);
test('execution page size remains adjustable when all runs fit on one page', function () {
$schedule = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
foreach (range(1, 11) as $index) {
$schedule->executions()->create(['status' => 'success']);
}
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->set('perPage', 25)
->assertSeeHtml('aria-label="Items per page"')
->assertDontSeeHtml('aria-label="Next page"')
->set('perPage', 10)
->assertSeeHtml('aria-label="Next page"');
});
test('service backup lists identify the configured S3 storage without extra columns', function () {
foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) {
$volume = LocalPersistentVolume::create([
'name' => 'service-data-'.str($name)->slug(),
'mount_path' => '/data',
'resource_id' => $this->ownServiceDatabase->id,
'resource_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$storage = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => $name, 'bucket' => 'backups']);
ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
'save_s3' => true,
's3_storage_id' => $storage->id,
]);
$volume->scheduledBackups()->create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'save_s3' => true,
's3_storage_id' => $storage->id,
]);
}
$html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html();
foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) {
expect(substr_count($html, 'data-tooltip="S3 storage: '.$name.' (bucket: backups)"'))->toBe(2);
}
});
test('execution tooltips distinguish current database storage from the recorded storage destination', function () {
$original = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Cloudflare R2', 'bucket' => 'original']);
$current = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Railway S3', 'bucket' => 'current']);
$databaseSchedule = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
'save_s3' => true,
's3_storage_id' => $current->id,
]);
$databaseSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true]);
$volume = LocalPersistentVolume::create([
'name' => 'service-data',
'mount_path' => '/data',
'resource_id' => $this->ownServiceDatabase->id,
'resource_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$volumeSchedule = $volume->scheduledBackups()->create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'save_s3' => true,
's3_storage_id' => $current->id,
]);
$volumeSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true, 's3_storage_id' => $original->id]);
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertSeeHtml('data-tooltip="Current schedule S3 storage: Railway S3 (bucket: current)"')
->assertSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"');
$current->update(['team_id' => $this->teamB->id]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->assertDontSee('Railway S3')
->assertSeeHtml('data-tooltip="S3 storage: Unavailable"');
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertDontSee('Railway S3')
->assertSeeHtml('data-tooltip="Current schedule S3 storage: Unavailable"');
$databaseSchedule->update(['save_s3' => false]);
$original->delete();
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertSeeHtml('data-tooltip="Current schedule S3 storage: Not configured"')
->assertDontSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"')
->assertSeeHtml('data-tooltip="S3 storage: Unavailable"');
});
@@ -168,3 +168,38 @@ test('server shared variable dev view updates existing variable', function () {
expect($var->value)->toBe('new_value')
->and($var->comment)->toBe('updated comment');
});
test('server shared variables display built-ins as read-only rows', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid])
->assertSee('COOLIFY_SERVER_UUID')
->assertSee('COOLIFY_SERVER_NAME')
->assertSee('Built-in · Read-only')
->assertDontSee('Add a variable to make it available to resources in this scope.')
->assertDontSee('data-env-settings-trigger', false)
->assertSet('variables', '')
->call('switch')
->assertSee('COOLIFY_SERVER_UUID')
->assertSee('COOLIFY_SERVER_NAME')
->assertSet('variables', '')
->set('variables', "COOLIFY_SERVER_UUID=changed\nCOOLIFY_SERVER_NAME=changed\nCUSTOM=value")
->call('submit');
expect($server->environment_variables()->pluck('value', 'key')->all())
->toMatchArray(['COOLIFY_SERVER_UUID' => $server->uuid, 'COOLIFY_SERVER_NAME' => $server->name, 'CUSTOM' => 'value']);
});
test('server built-ins are visible to team members but not other teams', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid])
->assertSee('COOLIFY_SERVER_UUID')
->assertDontSee('Add variable');
$otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]);
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $otherServer->uuid])
->assertRedirect(route('dashboard'))
->assertDontSee($otherServer->uuid);
});