diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index 26836b89a..f58e2ee71 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -7,6 +7,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -148,11 +149,19 @@ public function create(Request $request, string $server_uuid): JsonResponse return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); } - $destination = $class::create([ - 'name' => $name, - 'network' => $request->input('network'), - 'server_id' => $server->id, - ]); + try { + $destination = $class::create([ + 'name' => $name, + 'network' => $request->input('network'), + 'server_id' => $server->id, + ]); + } catch (QueryException $exception) { + if ($this->isUniqueConstraintViolation($exception)) { + return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); + } + + throw $exception; + } auditLog('api.destination.created', [ 'team_id' => $teamId, @@ -165,6 +174,15 @@ public function create(Request $request, string $server_uuid): JsonResponse return response()->json($this->transform($destination->load('server:id,uuid')), 201); } + private function isUniqueConstraintViolation(QueryException $exception): bool + { + $sqlState = $exception->errorInfo[0] ?? null; + $driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode()); + + return in_array($sqlState, ['23000', '23505'], true) + || in_array($driverCode, ['19', '1062', '2067'], true); + } + public function delete(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php index 9639c5d9e..027f4acdb 100644 --- a/tests/Feature/Api/DestinationsApiTest.php +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -10,6 +10,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -193,6 +194,35 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string $response->assertStatus(409); }); + + test('returns conflict when the database unique constraint wins a create race', function () { + $network = 'raced-network'; + + StandaloneDocker::creating(function (StandaloneDocker $destination) use ($network) { + if ($destination->network !== $network) { + return; + } + + DB::table('standalone_dockers')->insert([ + 'name' => 'Concurrent destination', + 'uuid' => (string) Str::uuid(), + 'network' => $network, + 'server_id' => $destination->server_id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'network' => $network, + ]); + + $response->assertStatus(409) + ->assertJson(['message' => 'A destination with this network already exists on the server.']); + + expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', $network)->count())->toBe(1); + }); }); describe('DELETE /api/v1/destinations/{uuid}', function () {