mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-28 02:06:37 -04:00
fix(livewire): prevent stale keys and refresh races
Use stable identities for dynamic rows and nested components so reordering does not target the wrong item. Scope storage count and volume-list refreshes, preserve PostgreSQL script identity when renaming files, use Livewire-aware destination redirects, and bump the version to 4.3.13.
This commit is contained in:
@@ -402,6 +402,21 @@ it('removes a domain', function () {
|
||||
expect($this->application->fqdn)->toBe('https://www.example.com');
|
||||
});
|
||||
|
||||
it('removes consecutive domains by stable row identity after indexes change', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://first.example.com,https://second.example.com,https://third.example.com',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
|
||||
$component
|
||||
->call('removeDomainByKey', hash('sha256', 'https://first.example.com|'))
|
||||
->call('removeDomainByKey', hash('sha256', 'https://second.example.com|'))
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBe('https://third.example.com');
|
||||
});
|
||||
|
||||
it('does not revalidate dns on remaining domains when removing one', function () {
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
|
||||
@@ -135,13 +135,44 @@ test('livewire volume storage refreshes the storage list and configuration warni
|
||||
->set('mount_path', '/app/data')
|
||||
->call('submitPersistentVolume')
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('refreshStorages')
|
||||
->assertNotDispatched('refreshStorages')
|
||||
->assertNotDispatched('refreshVolumeList')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertSet('activeTab', 'volumes')
|
||||
->assertSet('volumeCount', 1)
|
||||
->assertSee($this->application->uuid.'-data');
|
||||
});
|
||||
|
||||
test('adding another volume refreshes only the mounted volume list', function () {
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-first',
|
||||
'mount_path' => '/app/first',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('name', 'second')
|
||||
->set('mount_path', '/app/second')
|
||||
->call('submitPersistentVolume')
|
||||
->assertDispatched('refreshVolumeList')
|
||||
->assertNotDispatched('refreshStorages');
|
||||
});
|
||||
|
||||
test('deleting a volume asks only the parent storage component to refresh its counts', function () {
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-data',
|
||||
'mount_path' => '/app/data',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $this->application])
|
||||
->call('delete', $volume->id, 'password')
|
||||
->assertDispatched('storageCountsChanged')
|
||||
->assertNotDispatched('refreshStorages');
|
||||
});
|
||||
|
||||
test('volume storage list shows volumes added after it was mounted', function () {
|
||||
$firstVolume = LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-first',
|
||||
@@ -191,7 +222,9 @@ test('deleting a file mount refreshes the configuration warning', function () {
|
||||
|
||||
Livewire::test(FileStorage::class, ['fileStorage' => $file])
|
||||
->call('delete', 'password')
|
||||
->assertDispatched('configurationChanged');
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('storageCountsChanged')
|
||||
->assertNotDispatched('refreshStorages');
|
||||
|
||||
expect($file->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
@@ -33,6 +33,16 @@ it('keeps storage backup schedule tables horizontally scrollable on mobile', fun
|
||||
->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/');
|
||||
});
|
||||
|
||||
it('keeps nested storage component keys stable when mounts are added or deleted', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:key="volumes-{{ $resource->id }}"')
|
||||
->toContain('wire:key="svc-volumes-{{ $resource->id }}"')
|
||||
->not->toContain('wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"')
|
||||
->not->toContain('wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"');
|
||||
});
|
||||
|
||||
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
|
||||
use App\Livewire\Project\Shared\Storages\All;
|
||||
use App\Models\Application;
|
||||
|
||||
@@ -127,6 +127,21 @@ it('groups configured domains and shows redirect settings in the table', functio
|
||||
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
|
||||
});
|
||||
|
||||
it('removes consecutive service domains by stable row identity after indexes change', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://first.example.com,https://second.example.com,https://third.example.com',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]);
|
||||
|
||||
$component
|
||||
->call('removeDomainByKey', hash('sha256', 'https://first.example.com|'.$this->apiApp->id))
|
||||
->call('removeDomainByKey', hash('sha256', 'https://second.example.com|'.$this->apiApp->id))
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->apiApp->fresh()->fqdn)->toBe('https://third.example.com');
|
||||
});
|
||||
|
||||
it('shows and persists the HTTP redirect control for HTTPS service applications', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSee('Redirect HTTP to HTTPS')
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
it('does not key nested Livewire components by mutable list positions', function () {
|
||||
$postgresView = file_get_contents(resource_path('views/livewire/project/database/postgresql/general.blade.php'));
|
||||
$proxyView = file_get_contents(resource_path('views/livewire/server/proxy/dynamic-configurations.blade.php'));
|
||||
|
||||
expect($postgresView)
|
||||
->toContain(':wire:key="\'init-script-\'.md5($script[\'filename\'])"')
|
||||
->not->toContain(':wire:key="$script[\'index\']"');
|
||||
|
||||
expect($proxyView)
|
||||
->toContain('wire:key="proxy-navbar-{{ $fileName }}"')
|
||||
->not->toContain('wire:key="{{ $fileName }}-{{ $loop->index }}"');
|
||||
});
|
||||
|
||||
it('saves reindexed PostgreSQL scripts by their original stable identity', function () {
|
||||
$editor = file_get_contents(app_path('Livewire/Project/Database/InitScript.php'));
|
||||
$parent = file_get_contents(app_path('Livewire/Project/Database/Postgresql/General.php'));
|
||||
|
||||
expect($editor)
|
||||
->toContain('public string $originalFilename;')
|
||||
->toContain("dispatch('save_init_script', \$this->script, \$this->originalFilename)");
|
||||
|
||||
expect($parent)
|
||||
->toContain('public function save_init_script($script, string $originalFilename)')
|
||||
->toContain("firstWhere('filename', \$originalFilename)");
|
||||
});
|
||||
|
||||
it('keeps editable and refreshed list row keys independent of their positions', function () {
|
||||
$applicationDomains = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
$serviceDomains = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
|
||||
$scheduledJobs = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
|
||||
|
||||
expect($applicationDomains)
|
||||
->not->toContain('wire:key="domain-row-{{ $index }}-')
|
||||
->toContain('wire:key="domain-row-{{ md5(');
|
||||
|
||||
expect($serviceDomains)
|
||||
->not->toContain('-{{ $index }}-')
|
||||
->toContain('wire:key="svc-domain-{{ $row[\'service_application_id\'] ?? \'x\' }}-{{ md5(');
|
||||
|
||||
expect($scheduledJobs)
|
||||
->not->toContain('wire:key="run-{{ $loop->index }}"')
|
||||
->not->toContain('wire:key="skip-{{ $loop->index }}"')
|
||||
->toContain('wire:key="run-{{ md5(serialize($run)) }}"')
|
||||
->toContain('wire:key="skip-{{ md5(serialize($skip)) }}"');
|
||||
});
|
||||
|
||||
it('keys nested Livewire status components rendered inside navigation loops', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain(':key="\'application-server-status-\'.$application->uuid"');
|
||||
});
|
||||
|
||||
it('keys rolling log lines by content occurrence instead of list position', function () {
|
||||
$logs = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
|
||||
|
||||
expect($logs)
|
||||
->toContain('$lineOccurrences = [];')
|
||||
->toContain('$lineFingerprint = md5($line);')
|
||||
->toContain('wire:key="log-{{ $lineFingerprint }}-{{ $lineOccurrence }}"')
|
||||
->not->toContain("'line-' . \$index");
|
||||
});
|
||||
@@ -275,6 +275,23 @@ describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () {
|
||||
});
|
||||
|
||||
describe('Destination/Show team scope', function () {
|
||||
test('deleting a destination redirects without rendering the deleted destination', function () {
|
||||
$destination = SwarmDocker::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'swarm-a-'.fake()->unique()->word(),
|
||||
'network' => 'swarm-a-'.fake()->unique()->word(),
|
||||
'server_id' => $this->serverA->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $destination->uuid])
|
||||
->call('delete')
|
||||
->assertRedirect(route('destination.index'));
|
||||
|
||||
expect($component->effects)
|
||||
->toHaveKey('redirectUsingNavigate', true);
|
||||
expect($destination->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
test('mount with other team destination UUID redirects to index', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]);
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve
|
||||
->toContain('ARG COOLIFY_VERSION')
|
||||
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
|
||||
->and($constants)
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.11')
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.13'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.13')
|
||||
->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1')
|
||||
->and($nightlyVersions)->toBe($versions);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user