From 36bf068814144032924129d7814dbe2ce9b7d0be Mon Sep 17 00:00:00 2001 From: Yaroslav Novykov Date: Tue, 26 May 2026 12:51:54 +0300 Subject: [PATCH 1/4] fix(api): apply private_key_uuid in update_server The endpoint validated private_key_uuid but dropped it from the update, so the request silently no-op'd. Resolve the UUID to a team-scoped PrivateKey and include private_key_id in the update payload. --- app/Http/Controllers/Api/ServersController.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 6c3b2da00..24c45e567 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -706,7 +706,15 @@ public function update_server(Request $request) return response()->json(['message' => 'Invalid proxy type.'], 422); } } - $server->update($request->only(['name', 'description', 'ip', 'port', 'user'])); + $updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']); + if ($request->filled('private_key_uuid')) { + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + $updateFields['private_key_id'] = $privateKey->id; + } + $server->update($updateFields); if ($request->is_build_server) { $server->settings()->update([ 'is_build_server' => $request->is_build_server, From da70e344724da4c4fd17a56a4787a3c9446efa51 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:43:52 +0200 Subject: [PATCH 2/4] test(api): cover server private key updates Add feature coverage for updating a server from private_key_uuid, rejecting unknown or cross-team private keys, and preserving the existing key when the field is omitted. --- .../Feature/ServerUpdatePrivateKeyApiTest.php | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/Feature/ServerUpdatePrivateKeyApiTest.php diff --git a/tests/Feature/ServerUpdatePrivateKeyApiTest.php b/tests/Feature/ServerUpdatePrivateKeyApiTest.php new file mode 100644 index 000000000..e3f315f24 --- /dev/null +++ b/tests/Feature/ServerUpdatePrivateKeyApiTest.php @@ -0,0 +1,94 @@ +set('app.maintenance.driver', 'file'); + config()->set('cache.default', 'array'); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->oldPrivateKey = createServerUpdatePrivateKeyApiKey($this->team, 'Old Key'); + $this->newPrivateKey = createServerUpdatePrivateKeyApiKey($this->team, 'New Key'); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->oldPrivateKey->id, + ]); + + $token = $this->user->createToken('write-token', ['write']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->bearerToken = $token->plainTextToken; +}); + +function createServerUpdatePrivateKeyApiKey(Team $team, string $name): PrivateKey +{ + return PrivateKey::create([ + 'name' => $name, + 'private_key' => generateSSHKey('ed25519')['private'], + 'team_id' => $team->id, + ]); +} + +function patchServerUpdatePrivateKeyApi(object $test, Server $server, string $bearerToken, array $payload): TestResponse +{ + return $test->withHeaders([ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$server->uuid, $payload); +} + +it('updates the server private key from private_key_uuid', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'private_key_uuid' => $this->newPrivateKey->uuid, + ])->assertCreated() + ->assertJson(['uuid' => $this->server->uuid]); + + expect($this->server->fresh()->private_key_id)->toBe($this->newPrivateKey->id); +}); + +it('returns not found for an unknown private_key_uuid and leaves the key unchanged', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'private_key_uuid' => 'unknown-private-key-uuid', + ])->assertNotFound() + ->assertJson(['message' => 'Private key not found.']); + + expect($this->server->fresh()->private_key_id)->toBe($this->oldPrivateKey->id); +}); + +it('does not allow attaching a private key from another team', function () { + $otherTeam = Team::factory()->create(); + $otherTeamPrivateKey = createServerUpdatePrivateKeyApiKey($otherTeam, 'Other Team Key'); + + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'private_key_uuid' => $otherTeamPrivateKey->uuid, + ])->assertNotFound() + ->assertJson(['message' => 'Private key not found.']); + + expect($this->server->fresh()->private_key_id)->toBe($this->oldPrivateKey->id); +}); + +it('keeps the existing private key when private_key_uuid is omitted', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'name' => 'Renamed Server', + ])->assertCreated() + ->assertJson(['uuid' => $this->server->uuid]); + + $server = $this->server->fresh(); + + expect($server->name)->toBe('Renamed Server') + ->and($server->private_key_id)->toBe($this->oldPrivateKey->id); +}); From 09d8ba0d89ac0cbed091d06530ce317f1e2918b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:03:12 +0200 Subject: [PATCH 3/4] fix(api): prevent partial server updates on invalid disk schedule --- .../Controllers/Api/ServersController.php | 21 +++++++++------- .../Feature/ServerUpdatePrivateKeyApiTest.php | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 9b008d90c..5082bd856 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -705,9 +705,7 @@ public function update_server(Request $request) $validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) { return str($proxyType->value)->lower(); }); - if ($validProxyTypes->contains(str($request->proxy_type)->lower())) { - $server->changeProxy($request->proxy_type, async: true); - } else { + if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) { return response()->json(['message' => 'Invalid proxy type.'], 422); } } @@ -719,12 +717,6 @@ public function update_server(Request $request) } $updateFields['private_key_id'] = $privateKey->id; } - $server->update($updateFields); - if ($request->is_build_server) { - $server->settings()->update([ - 'is_build_server' => $request->is_build_server, - ]); - } if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) { return response()->json([ @@ -733,11 +725,22 @@ public function update_server(Request $request) ], 422); } + $server->update($updateFields); + if ($request->is_build_server) { + $server->settings()->update([ + 'is_build_server' => $request->is_build_server, + ]); + } + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']); if (! empty($advancedSettings)) { $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); } + if ($request->proxy_type) { + $server->changeProxy($request->proxy_type, async: true); + } + if ($request->instant_validate) { ValidateServer::dispatch($server); } diff --git a/tests/Feature/ServerUpdatePrivateKeyApiTest.php b/tests/Feature/ServerUpdatePrivateKeyApiTest.php index e3f315f24..a6e407168 100644 --- a/tests/Feature/ServerUpdatePrivateKeyApiTest.php +++ b/tests/Feature/ServerUpdatePrivateKeyApiTest.php @@ -92,3 +92,28 @@ function patchServerUpdatePrivateKeyApi(object $test, Server $server, string $be expect($server->name)->toBe('Renamed Server') ->and($server->private_key_id)->toBe($this->oldPrivateKey->id); }); + +it('rejects an invalid disk usage check frequency without partially updating the server', function () { + $this->server->proxy->set('type', 'TRAEFIK'); + $this->server->save(); + $this->server->settings()->update(['is_build_server' => false]); + + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'name' => 'Renamed Server', + 'is_build_server' => true, + 'proxy_type' => 'none', + 'server_disk_usage_check_frequency' => 'not a valid schedule', + ])->assertUnprocessable() + ->assertJson([ + 'message' => 'Validation failed.', + 'errors' => [ + 'server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.'], + ], + ]); + + $server = $this->server->fresh(); + + expect($server->name)->not->toBe('Renamed Server') + ->and($server->settings->is_build_server)->toBeFalse() + ->and($server->proxy->get('type'))->toBe('TRAEFIK'); +}); From 217541a98776271066128fbc0a27332b6af387e1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:48:31 +0200 Subject: [PATCH 4/4] fix(api): allow disabling build server mode --- app/Http/Controllers/Api/ServersController.php | 4 ++-- tests/Feature/ServerUpdatePrivateKeyApiTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 5082bd856..e43026a72 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -726,9 +726,9 @@ public function update_server(Request $request) } $server->update($updateFields); - if ($request->is_build_server) { + if ($request->has('is_build_server')) { $server->settings()->update([ - 'is_build_server' => $request->is_build_server, + 'is_build_server' => $request->boolean('is_build_server'), ]); } diff --git a/tests/Feature/ServerUpdatePrivateKeyApiTest.php b/tests/Feature/ServerUpdatePrivateKeyApiTest.php index a6e407168..8bc5f1d0e 100644 --- a/tests/Feature/ServerUpdatePrivateKeyApiTest.php +++ b/tests/Feature/ServerUpdatePrivateKeyApiTest.php @@ -93,6 +93,17 @@ function patchServerUpdatePrivateKeyApi(object $test, Server $server, string $be ->and($server->private_key_id)->toBe($this->oldPrivateKey->id); }); +it('can disable build server mode via API', function () { + $this->server->settings()->update(['is_build_server' => true]); + + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'is_build_server' => false, + ])->assertCreated() + ->assertJson(['uuid' => $this->server->uuid]); + + expect($this->server->settings->fresh()->is_build_server)->toBeFalse(); +}); + it('rejects an invalid disk usage check frequency without partially updating the server', function () { $this->server->proxy->set('type', 'TRAEFIK'); $this->server->save();