fix(api): validate delete_from_provider before deleting a server

Reject delete_from_provider values that are not booleans, and reject the
request when the team has no token for a linked cloud provider. Before,
both cases removed the server from Coolify but kept the cloud VM running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-28 11:42:48 +02:00
co-authored by Claude Opus 5.5
parent f885e1eeeb
commit 4145261b6e
2 changed files with 61 additions and 7 deletions
+19 -3
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
@@ -900,9 +901,24 @@ class ServersController extends Controller
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
$deleteFromProvider = filter_var($request->query('delete_from_provider', false), FILTER_VALIDATE_BOOLEAN);
if ($deleteFromProvider && ! $server->hetzner_server_id && ! $server->vultr_instance_id && ! $server->digitalocean_droplet_id) {
return response()->json(['message' => 'Server is not linked to a cloud provider.'], 422);
$deleteFromProvider = filter_var($request->query('delete_from_provider', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if (is_null($deleteFromProvider)) {
return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422);
}
if ($deleteFromProvider) {
$linkedProviders = array_keys(array_filter([
'hetzner' => $server->hetzner_server_id,
'vultr' => $server->vultr_instance_id,
'digitalocean' => $server->digitalocean_droplet_id,
]));
if (empty($linkedProviders)) {
return response()->json(['message' => 'Server is not linked to a cloud provider.'], 422);
}
foreach ($linkedProviders as $provider) {
if (! CloudProviderToken::whereTeamId($server->team_id)->whereProvider($provider)->exists()) {
return response()->json(['message' => "No {$provider} token found for this team. Add one before deleting the server from the cloud provider."], 422);
}
}
}
if ($force) {
@@ -1,6 +1,7 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
@@ -41,7 +42,8 @@ it('does not delete from the cloud provider by default', function () {
&& $params[7] === false);
});
it('deletes from the linked cloud provider when delete_from_provider is true', function (array $attributes) {
it('deletes from the linked cloud provider when delete_from_provider is true', function (string $provider, array $attributes) {
CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => $provider]);
$server = Server::factory()->create(['team_id' => $this->team->id, ...$attributes]);
deleteServerViaApi($this->token, $server->uuid, '?delete_from_provider=true')->assertOk();
@@ -51,9 +53,25 @@ it('deletes from the linked cloud provider when delete_from_provider is true', f
&& $params[5] === true
&& $params[7] === true);
})->with([
'hetzner' => [['hetzner_server_id' => 123]],
'vultr' => [['vultr_instance_id' => 'vultr-instance-id']],
'digitalocean' => [['digitalocean_droplet_id' => 456]],
'hetzner' => ['hetzner', ['hetzner_server_id' => 123]],
'vultr' => ['vultr', ['vultr_instance_id' => 'vultr-instance-id']],
'digitalocean' => ['digitalocean', ['digitalocean_droplet_id' => 456]],
]);
it('keeps the server when the team has no token for the linked cloud provider', function (string $provider, array $attributes) {
CloudProviderToken::factory()->create(['provider' => $provider]);
$server = Server::factory()->create(['team_id' => $this->team->id, ...$attributes]);
deleteServerViaApi($this->token, $server->uuid, '?delete_from_provider=true')
->assertStatus(422)
->assertJson(['message' => "No {$provider} token found for this team. Add one before deleting the server from the cloud provider."]);
expect(Server::find($server->id))->not->toBeNull();
DeleteServer::assertNotPushed();
})->with([
'hetzner' => ['hetzner', ['hetzner_server_id' => 123]],
'vultr' => ['vultr', ['vultr_instance_id' => 'vultr-instance-id']],
'digitalocean' => ['digitalocean', ['digitalocean_droplet_id' => 456]],
]);
it('rejects delete_from_provider when the server has no cloud provider link', function () {
@@ -78,3 +96,23 @@ it('does not delete a server of another team from the cloud provider', function
expect(Server::find($otherServer->id))->not->toBeNull();
DeleteServer::assertNotPushed();
});
it('rejects an invalid delete_from_provider value without deleting the server', function (string $value) {
CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']);
$server = Server::factory()->create(['team_id' => $this->team->id, 'hetzner_server_id' => 123]);
deleteServerViaApi($this->token, $server->uuid, "?delete_from_provider={$value}")
->assertStatus(422)
->assertJson(['message' => 'delete_from_provider must be a boolean.']);
expect(Server::find($server->id))->not->toBeNull();
DeleteServer::assertNotPushed();
})->with(['ture', 'yes-please', '2']);
it('deletes only from Coolify when delete_from_provider is false', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'hetzner_server_id' => 123]);
deleteServerViaApi($this->token, $server->uuid, '?delete_from_provider=false')->assertOk();
DeleteServer::assertPushed(fn ($action, array $params) => $params[0] === $server->id && $params[1] === false);
});