diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php
index 6fb87584e6..674ddef037 100644
--- a/tests/Feature/Api/DestinationsApiTest.php
+++ b/tests/Feature/Api/DestinationsApiTest.php
@@ -44,6 +44,7 @@ beforeEach(function () {
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
});
function destinationsApiHeaders(string $bearerToken): array
@@ -130,6 +131,18 @@ describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
->exists())->toBeTrue();
});
+ test('does not connect to a server that is not validated', function () {
+ $this->server->settings->update(['is_reachable' => false, 'is_usable' => false]);
+
+ $this->withHeaders(destinationsApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => 'api-standalone-network'])
+ ->assertStatus(400)
+ ->assertJsonPath('message', 'Server is not functional.');
+
+ Process::assertNothingRan();
+ expect(StandaloneDocker::where('network', 'api-standalone-network')->exists())->toBeFalse();
+ });
+
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
diff --git a/tests/Feature/Api/ServerDeployAbilityTest.php b/tests/Feature/Api/ServerDeployAbilityTest.php
index 085b3bd506..041796f5f6 100644
--- a/tests/Feature/Api/ServerDeployAbilityTest.php
+++ b/tests/Feature/Api/ServerDeployAbilityTest.php
@@ -41,6 +41,7 @@ beforeEach(function () {
]);
$this->server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$this->server->save();
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
});
function serverDeployAbilityHeaders(User $user, int $teamId, array $abilities): array
diff --git a/tests/Feature/Api/ServerProxyApiTest.php b/tests/Feature/Api/ServerProxyApiTest.php
index 9a18bb8cf1..2900b48c84 100644
--- a/tests/Feature/Api/ServerProxyApiTest.php
+++ b/tests/Feature/Api/ServerProxyApiTest.php
@@ -216,6 +216,7 @@ test('PATCH /api/v1/servers/{uuid}/proxy can change proxy type asynchronously',
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration saves base64 configuration via action', function () {
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
SaveProxyConfiguration::shouldRun()
@@ -241,6 +242,7 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configurati
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects command injection without mutation', function () {
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$originalProxy = $this->server->proxy->toArray();
$configuration = "services:\n traefik:\n ports:\n - '`id>/tmp/pwned`:443'\n";
@@ -283,6 +285,7 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the deploy permiss
});
test('PUT /api/v1/servers/{uuid}/proxy/configuration saves with write and deploy permissions', function () {
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$deployToken = serverProxyApiToken($this->user, $this->team, ['write', 'deploy']);
SaveProxyConfiguration::shouldRun()->once();
@@ -318,6 +321,7 @@ test('POST /api/v1/servers/{uuid}/proxy/restart requires the deploy permission',
});
test('POST /api/v1/servers/{uuid}/proxy/restart works with the deploy permission', function () {
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
Queue::fake();
$deployToken = serverProxyApiToken($this->user, $this->team, ['deploy']);
@@ -329,6 +333,7 @@ test('POST /api/v1/servers/{uuid}/proxy/restart works with the deploy permission
});
test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () {
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
Queue::fake();
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
@@ -342,6 +347,25 @@ test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', functio
);
});
+test('proxy configuration and restart endpoints do not connect to a server that is not validated', function () {
+ Queue::fake();
+ SaveProxyConfiguration::shouldNotRun();
+
+ $this->withHeaders(serverProxyApiHeaders($this->bearerToken))
+ ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
+ 'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"),
+ ])
+ ->assertStatus(400)
+ ->assertJsonPath('message', 'Server is not functional.');
+
+ $this->withHeaders(serverProxyApiHeaders($this->bearerToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")
+ ->assertStatus(400)
+ ->assertJsonPath('message', 'Server is not functional.');
+
+ Queue::assertNotPushed(RestartProxyJob::class);
+});
+
test('proxy endpoints require authentication', function () {
$this->getJson("/api/v1/servers/{$this->server->uuid}/proxy")->assertUnauthorized();
$this->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['redirect_enabled' => false])->assertUnauthorized();
diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php
index a131bf22ab..712534538a 100644
--- a/tests/Feature/Api/ServerSubsystemsApiTest.php
+++ b/tests/Feature/Api/ServerSubsystemsApiTest.php
@@ -1,6 +1,8 @@
and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123');
});
+ test('PATCH saves log drain settings without connecting to a server that is not validated', function () {
+ Queue::fake();
+
+ $this->withHeaders(serverSubsystemsHeaders())
+ ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
+ 'is_logdrain_axiom_enabled' => false,
+ ])
+ ->assertOk();
+
+ StopLogDrain::assertNotPushed();
+ StartLogDrain::assertNotPushed();
+ });
+
test('PATCH requires the deploy permission to change the custom log drain config', function (string $field) {
Queue::fake();
$writeOnlyToken = $this->user->createToken('server-subsystems-write', ['write'])->plainTextToken;
diff --git a/tests/Feature/ServerMetadataTest.php b/tests/Feature/ServerMetadataTest.php
index 5d521a71e6..520f28f7ed 100644
--- a/tests/Feature/ServerMetadataTest.php
+++ b/tests/Feature/ServerMetadataTest.php
@@ -203,9 +203,9 @@ it('calls gatherServerMetadata during ValidateAndInstall when docker version is
// Call the action on the mounted instance: a Livewire request round trip
// would rehydrate the server from the database and drop the mock.
- Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])
- ->instance()
- ->validateDockerVersion();
+ $component = Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])->instance();
+ $component->uptime = '1 day';
+ $component->validateDockerVersion();
});
it('does not call gatherServerMetadata when docker version validation fails', function () {
@@ -216,9 +216,9 @@ it('does not call gatherServerMetadata when docker version validation fails', fu
// Call the action on the mounted instance: a Livewire request round trip
// would rehydrate the server from the database and drop the mock.
- Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])
- ->instance()
- ->validateDockerVersion();
+ $component = Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])->instance();
+ $component->uptime = '1 day';
+ $component->validateDockerVersion();
});
it('stores detected NVIDIA GPUs and the container toolkit when gathering server metadata', function (array $gpuLines, array $toolkitLines, array $expectedGpus, bool $expectedToolkit) {
diff --git a/tests/Feature/ServerValidationCooldownTest.php b/tests/Feature/ServerValidationCooldownTest.php
new file mode 100644
index 0000000000..c88fa3ceb5
--- /dev/null
+++ b/tests/Feature/ServerValidationCooldownTest.php
@@ -0,0 +1,190 @@
+ 0, 'is_api_enabled' => true]);
+ config(['constants.coolify.self_hosted' => false, 'constants.ssh.mux_enabled' => false]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
+ ]);
+ $this->token = $this->user->createToken('server-validation', ['write'])->plainTextToken;
+
+ Queue::fake();
+});
+
+function validateServerViaApi(Server $server): TestResponse
+{
+ return test()->withHeaders(['Authorization' => 'Bearer '.test()->token])
+ ->postJson("/api/v1/servers/{$server->uuid}/validate");
+}
+
+it('blocks API validations of one IP after 10 attempts on cloud', function () {
+ foreach (range(1, 10) as $attempt) {
+ validateServerViaApi($this->server)->assertCreated();
+ }
+
+ validateServerViaApi($this->server)
+ ->assertStatus(429)
+ ->assertHeader('Retry-After')
+ ->assertJsonPath('message', fn (string $message): bool => str_starts_with($message, 'Too many server validations.'));
+ ValidateServer::assertPushed(10);
+});
+
+it('uses the rate limit window from the instance settings', function () {
+ InstanceSettings::findOrFail(0)->forceFill(['server_validation_rate_limit_seconds' => 120])->save();
+ Once::flush();
+
+ foreach (range(1, 10) as $attempt) {
+ validateServerViaApi($this->server)->assertCreated();
+ }
+
+ $retryAfter = (int) validateServerViaApi($this->server)->assertStatus(429)->headers->get('Retry-After');
+ expect($retryAfter)->toBeGreaterThan(0)->toBeLessThanOrEqual(120);
+});
+
+it('uses the maximum failed validations per IP and per team from the instance settings', function () {
+ InstanceSettings::findOrFail(0)->forceFill([
+ 'server_validation_max_failures_per_ip' => 2,
+ 'server_validation_max_failures_per_team' => 3,
+ ])->save();
+ Once::flush();
+ $otherServer = Server::factory()->create(['team_id' => $this->team->id]);
+
+ validateServerViaApi($this->server)->assertCreated();
+ validateServerViaApi($this->server)->assertCreated();
+ validateServerViaApi($this->server)->assertStatus(429);
+
+ validateServerViaApi($otherServer)->assertCreated();
+ validateServerViaApi($otherServer)->assertStatus(429);
+});
+
+it('lets an instance admin change the validation limits on cloud', function () {
+ $rootTeam = Team::factory()->create(['id' => 0]);
+ $rootTeam->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $rootTeam]);
+
+ Livewire::test(Advanced::class)
+ ->assertSet('server_validation_rate_limit_seconds', 600)
+ ->set('server_validation_rate_limit_seconds', 30)
+ ->call('submit')
+ ->assertDispatched('error');
+
+ expect(InstanceSettings::findOrFail(0)->server_validation_rate_limit_seconds)->toBe(600);
+
+ Livewire::test(Advanced::class)
+ ->assertSet('server_validation_max_failures_per_ip', 10)
+ ->assertSet('server_validation_max_failures_per_team', 30)
+ ->set('server_validation_rate_limit_seconds', 1800)
+ ->set('server_validation_max_failures_per_ip', 5)
+ ->set('server_validation_max_failures_per_team', 50)
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ expect(InstanceSettings::findOrFail(0))
+ ->server_validation_rate_limit_seconds->toBe(1800)
+ ->server_validation_max_failures_per_ip->toBe(5)
+ ->server_validation_max_failures_per_team->toBe(50);
+});
+
+it('blocks API validations of a team after 30 attempts on cloud, also for different IPs', function () {
+ $servers = Server::factory()->count(31)->create(['team_id' => $this->team->id]);
+
+ foreach ($servers->take(30) as $server) {
+ validateServerViaApi($server)->assertCreated();
+ }
+
+ validateServerViaApi($servers->last())->assertStatus(429);
+});
+
+it('blocks validations of one IP from different teams', function () {
+ $otherTeam = Team::factory()->create();
+
+ foreach (range(1, 10) as $attempt) {
+ Server::throttleValidation($this->team->id, $this->server->ip);
+ }
+
+ expect(fn () => Server::throttleValidation($otherTeam->id, $this->server->ip))
+ ->toThrow(ThrottleRequestsException::class);
+});
+
+it('does not limit validations on self-hosted instances', function () {
+ config(['constants.coolify.self_hosted' => true]);
+
+ foreach (range(1, 31) as $attempt) {
+ validateServerViaApi($this->server)->assertCreated();
+ }
+});
+
+it('gives the attempt back when the connection succeeds', function () {
+ Process::fake();
+
+ foreach (range(1, 12) as $attempt) {
+ Server::throttleValidation($this->team->id, $this->server->ip);
+ expect($this->server->validateConnection()['uptime'])->toBeTrue();
+ }
+
+ validateServerViaApi($this->server)->assertCreated();
+});
+
+it('tries a failed SSH login only once', function () {
+ config(['constants.ssh.retry_base_delay' => 0, 'constants.ssh.retry_max_delay' => 0]);
+ Process::fake(['*' => Process::result(errorOutput: 'ssh: connect to host 192.0.2.1 port 22: Connection refused', exitCode: 255)]);
+
+ expect($this->server->validateConnection()['uptime'])->toBeFalse();
+
+ Process::assertRanTimes(fn ($process): bool => str_contains(implode(' ', (array) $process->command), 'ssh'), 1);
+});
+
+it('does not connect to the server when the retry button is throttled on cloud', function () {
+ foreach (range(1, 10) as $attempt) {
+ Server::throttleValidation($this->team->id, $this->server->ip);
+ }
+
+ $this->actingAs($this->user);
+ $serverMock = Mockery::mock($this->server)->makePartial();
+ $serverMock->shouldNotReceive('validateConnection');
+
+ $component = Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])->instance();
+ $component->retry();
+
+ expect($component->uptime)->toBeNull();
+});
+
+it('skips the later validation steps when no connection succeeded', function () {
+ $this->actingAs($this->user);
+ $serverMock = Mockery::mock($this->server)->makePartial();
+ $serverMock->shouldNotReceive('validateOS');
+ $serverMock->shouldNotReceive('validatePrerequisites');
+ $serverMock->shouldNotReceive('validateDockerEngine');
+ $serverMock->shouldNotReceive('validateDockerEngineVersion');
+
+ $component = Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])->instance();
+ $component->validateOS();
+ $component->validatePrerequisites();
+ $component->validateDockerEngine();
+ $component->validateDockerVersion();
+});