From e8733b71b78f259a1d82f5ff27405b811c0f3575 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:27:21 +0200 Subject: [PATCH] fix(destinations): prevent duplicate additional destinations Deduplicate existing destination records, enforce uniqueness at the database level, and avoid reattaching already-linked destinations. --- app/Livewire/Project/Shared/Destination.php | 4 +- ...move_duplicate_additional_destinations.php | 37 +++++++++++++++++++ ...index_to_additional_destinations_table.php | 31 ++++++++++++++++ .../CrossTeamDestinationAttachTest.php | 26 +++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_09_21_070754_remove_duplicate_additional_destinations.php create mode 100644 database/migrations/2026_09_21_070755_add_unique_index_to_additional_destinations_table.php diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 94fb4b4eb3..7555c9bb17 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -150,7 +150,9 @@ class Destination extends Component $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); - $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]); + $this->resource->additional_networks()->syncWithoutDetaching([ + $network->id => ['server_id' => $server->id], + ]); $this->dispatch('refresh'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/database/migrations/2026_09_21_070754_remove_duplicate_additional_destinations.php b/database/migrations/2026_09_21_070754_remove_duplicate_additional_destinations.php new file mode 100644 index 0000000000..e5e0b7d441 --- /dev/null +++ b/database/migrations/2026_09_21_070754_remove_duplicate_additional_destinations.php @@ -0,0 +1,37 @@ +select([ + 'application_id', + 'server_id', + 'standalone_docker_id', + DB::raw('MIN(id) as first_id'), + ]) + ->groupBy('application_id', 'server_id', 'standalone_docker_id') + ->havingRaw('COUNT(*) > 1') + ->get() + ->each(function (object $duplicate): void { + DB::table('additional_destinations') + ->where('application_id', $duplicate->application_id) + ->where('server_id', $duplicate->server_id) + ->where('standalone_docker_id', $duplicate->standalone_docker_id) + ->where('id', '!=', $duplicate->first_id) + ->delete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void {} +}; diff --git a/database/migrations/2026_09_21_070755_add_unique_index_to_additional_destinations_table.php b/database/migrations/2026_09_21_070755_add_unique_index_to_additional_destinations_table.php new file mode 100644 index 0000000000..1a7746f7c3 --- /dev/null +++ b/database/migrations/2026_09_21_070755_add_unique_index_to_additional_destinations_table.php @@ -0,0 +1,31 @@ +unique( + ['application_id', 'server_id', 'standalone_docker_id'], + 'additional_destinations_application_server_docker_unique' + ); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('additional_destinations', function (Blueprint $table) { + $table->dropUnique('additional_destinations_application_server_docker_unique'); + }); + } +}; diff --git a/tests/Feature/CrossTeamDestinationAttachTest.php b/tests/Feature/CrossTeamDestinationAttachTest.php index f79c9a1e02..cccfa2b540 100644 --- a/tests/Feature/CrossTeamDestinationAttachTest.php +++ b/tests/Feature/CrossTeamDestinationAttachTest.php @@ -10,6 +10,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; @@ -123,6 +124,31 @@ describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () { expect($additional->first()->id)->toBe($this->destinationA2->id); expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id); }); + + test('attaching the same server twice does not create duplicate destinations', function () { + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('addServer', $this->destinationA2->id, $this->serverA2->id) + ->call('addServer', $this->destinationA2->id, $this->serverA2->id); + + expect(DB::table('additional_destinations') + ->where('application_id', $this->applicationA->id) + ->where('standalone_docker_id', $this->destinationA2->id) + ->where('server_id', $this->serverA2->id) + ->count())->toBe(1); + }); + + test('the database rejects duplicate application server destinations', function () { + $destination = [ + 'application_id' => $this->applicationA->id, + 'server_id' => $this->serverA2->id, + 'standalone_docker_id' => $this->destinationA2->id, + ]; + + DB::table('additional_destinations')->insert($destination); + + expect(fn () => DB::table('additional_destinations')->insert($destination)) + ->toThrow(QueryException::class); + }); }); describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {