From 0217e2b0c029b88d851b050f8dc8c2bd61696ea5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:35:09 +0200 Subject: [PATCH] feat(server): improve private key and cloud link flows Add server private key generation from the dropdown, prevent deleting private keys that are still in use, and close cloud provider link modals after successful linking. --- app/Livewire/Security/PrivateKey/Show.php | 22 +- app/Livewire/Server/PrivateKey/Show.php | 27 + app/Livewire/Server/Show.php | 3 + app/View/Components/Forms/Button.php | 1 + .../views/components/forms/button.blade.php | 6 +- resources/views/components/helper.blade.php | 59 +- .../components/modal-confirmation.blade.php | 5 +- .../views/components/modal-input.blade.php | 3 +- .../security/private-key/show.blade.php | 2 +- .../server/private-key/show.blade.php | 100 +++- .../views/livewire/server/show.blade.php | 517 ++++++++++-------- .../Feature/CloudProviderLinkDropdownTest.php | 192 +++++++ .../Security/PrivateKeyDropdownTest.php | 37 ++ .../Feature/ServerPrivateKeyDropdownTest.php | 100 ++++ 14 files changed, 822 insertions(+), 252 deletions(-) create mode 100644 tests/Feature/CloudProviderLinkDropdownTest.php create mode 100644 tests/Feature/ServerPrivateKeyDropdownTest.php diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index fa7397d13..826289b88 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -4,6 +4,7 @@ use App\Models\PrivateKey; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -22,8 +23,12 @@ class Show extends Component public bool $isGitRelated = false; + public bool $isInUse = false; + public $public_key = 'Loading...'; + public string $deleteDisabledReason = 'This private key is currently used by a server, application, or Git app and cannot be deleted.'; + protected function rules(): array { return [ @@ -74,16 +79,17 @@ private function syncData(bool $toModel = false): void } } - public function mount() + public function mount(?string $private_key_uuid = null) { try { - $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail(); + $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail(); // Explicit authorization check - will throw 403 if not authorized $this->authorize('view', $this->private_key); $this->syncData(false); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + $this->isInUse = $this->private_key->isInUse(); + } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { abort(404); @@ -102,7 +108,15 @@ public function delete() { try { $this->authorize('delete', $this->private_key); - $this->private_key->safeDelete(); + + if ($this->private_key->isInUse()) { + $this->isInUse = true; + $this->dispatch('error', $this->deleteDisabledReason); + + return; + } + + $this->private_key->delete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); return redirectRoute($this, 'security.private-key.index'); diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 810b95ed4..54f3436dc 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -55,6 +55,33 @@ public function setPrivateKey($privateKeyId) } } + public function generatePrivateKey(string $type): void + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key); + $this->dispatch('success', 'Private key created successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function checkConnection() { try { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 46f562a9e..15af859c9 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -828,6 +828,7 @@ public function linkToHetzner() $this->hetznerSearchError = null; $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); @@ -958,6 +959,7 @@ public function linkToDigitalOcean() $this->digitalOceanSearchError = null; $this->dispatch('success', 'Server successfully linked to DigitalOcean!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); @@ -1082,6 +1084,7 @@ public function linkToVultr() $this->vultrSearchError = null; $this->dispatch('success', 'Server successfully linked to Vultr!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index 8511c87db..f03b36f0e 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -22,6 +22,7 @@ public function __construct( public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public ?string $tooltip = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php index f1efd8c6c..89b177c1f 100644 --- a/resources/views/components/forms/button.blade.php +++ b/resources/views/components/forms/button.blade.php @@ -1,4 +1,4 @@ -@if ($authDisabled) +@if ($authDisabled || filled($tooltip)) - You do not have permission to perform this action. + {{ $tooltip ?: 'You do not have permission to perform this action.' }} @endif diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 2102e48b1..900beddd0 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,6 +1,47 @@ -
merge(['class' => 'group relative inline-block align-middle']) }}> -
+
merge(['class' => 'inline-block align-middle']) }}> +
@isset($icon) {{ $icon }} @else @@ -9,11 +50,13 @@ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"> @endisset -
-
-
- {!! $helper !!} +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 5efc9102b..830b7e09c 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -6,6 +6,7 @@ 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, + 'disabledTooltip' => null, 'authDisabled' => false, 'dispatchAction' => false, 'submitAction' => 'delete', @@ -156,11 +157,11 @@ @else @if ($disabled) @if ($buttonFullWidth) - + {{ $buttonTitle }} @else - + {{ $buttonTitle }} @endif diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 0f7347e5c..96bb3467c 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,6 +8,7 @@ 'content' => null, 'closeOutside' => true, 'isFullWidth' => false, + 'wireIgnore' => true, ]) @php @@ -17,7 +18,7 @@
+ class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif> @if ($content)
{{ $content }} diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php index da94f3c48..db7218624 100644 --- a/resources/views/livewire/security/private-key/show.blade.php +++ b/resources/views/livewire/security/private-key/show.blade.php @@ -19,7 +19,7 @@ class="w-fit rounded-sm border border-coollabs bg-coollabs-50 px-2 py-1 text-xs @if (data_get($private_key, 'id') > 0) @can('delete', $private_key) id }})" :disabled="$isInUse" :disabledTooltip="$deleteDisabledReason" :actions="[ 'This private key will be permanently deleted.', 'All servers connected to this private key will stop working.', 'Any git app using this private key will stop working.', diff --git a/resources/views/livewire/server/private-key/show.blade.php b/resources/views/livewire/server/private-key/show.blade.php index 495e1596d..70dc7f587 100644 --- a/resources/views/livewire/server/private-key/show.blade.php +++ b/resources/views/livewire/server/private-key/show.blade.php @@ -9,9 +9,54 @@

Private Key

@can('createAnyResource') - - - +
+ + + Add + + + + + +
+
+
+ + + + + Generate ED25519 + + + + + + Generate RSA + + + + + + + +
+
+
+
@endcan Check connection @@ -26,15 +71,21 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
{{ $private_key->name }}
{{ $private_key->description }}
- @if (data_get($server, 'privateKey.uuid') !== $private_key->uuid) - - Use this key +
+ + Copy public key - @else - - Currently used - - @endif + @if (data_get($server, 'privateKey.uuid') !== $private_key->uuid) + + Use this key + + @else + + Currently used + + @endif +
@empty
No private keys found.
@@ -42,4 +93,31 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
+ + @script + + @endscript
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d03d10dfb..f8f231e4c 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -212,6 +212,11 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn Validating...
@endif + @php + $hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) + || (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) + || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()); + @endphp @if ($server->id === 0) Save + @if ($hasLinkableCloudProviders) +
+ + Link Cloud Provider + + + + +
+
+
+ @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) + + + + +
+

+ Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. +

+
+ + + @foreach ($availableHetznerTokens as $token) + + @endforeach + +
+
+
+
+ +
+ + Search + Searching... + +
+
+
+ OR +
+
+ + Search by IP + Searching... + +
+ @if ($hetznerSearchError) +
+

{{ $hetznerSearchError }}

+
+ @endif + @if ($hetznerNoMatchFound) +
+

+ @if ($manualHetznerServerId) + No Hetzner server found with ID: {{ $manualHetznerServerId }} + @else + No Hetzner server found matching IP: {{ $server->ip }} + @endif +

+

+ Try a different token, enter the Server ID manually, or verify the details are correct. +

+
+ @endif + @if ($matchedHetznerServer) +
+

Match Found!

+
+
Name: {{ $matchedHetznerServer['name'] }}
+
ID: {{ $matchedHetznerServer['id'] }}
+
Status: {{ ucfirst($matchedHetznerServer['status']) }}
+
Type: {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}
+
+ + Link This Server + +
+ @endif +
+
+ @endif + @if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) + + + + +
+

+ Link this server to a DigitalOcean droplet to enable power controls and status monitoring. +

+
+ + + @foreach ($availableDigitalOceanTokens as $token) + + @endforeach + +
+
+
+
+ +
+ + Search + Searching... + +
+
+
+ OR +
+
+ + Search by IP + Searching... + +
+ @if ($digitalOceanSearchError) +
+

{{ $digitalOceanSearchError }}

+
+ @endif + @if ($digitalOceanNoMatchFound) +
+

+ @if ($manualDigitalOceanDropletId) + No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} + @else + No DigitalOcean droplet found matching IP: {{ $server->ip }} + @endif +

+

+ Try a different token, enter the Droplet ID manually, or verify the details are correct. +

+
+ @endif + @if ($matchedDigitalOceanDroplet) +
+

Match Found!

+
+
Name: {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}
+
ID: {{ $matchedDigitalOceanDroplet['id'] }}
+
Status: {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}
+
Size: {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}
+
+ + Link This Server + +
+ @endif +
+
+ @endif + @if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) + + + + +
+

+ Link this server to a Vultr instance to enable power controls and status monitoring. +

+
+ + + @foreach ($availableVultrTokens as $token) + + @endforeach + +
+
+
+
+ +
+ + Search + Searching... + +
+
+
+ OR +
+
+ + Search by IP + Searching... + +
+ @if ($vultrSearchError) +
+

{{ $vultrSearchError }}

+
+ @endif + @if ($vultrNoMatchFound) +
+

+ @if ($manualVultrInstanceId) + No Vultr instance found with ID: {{ $manualVultrInstanceId }} + @else + No Vultr instance found matching IP: {{ $server->ip }} + @endif +

+

+ Try a different token, enter the Instance ID manually, or verify the details are correct. +

+
+ @endif + @if ($matchedVultrInstance) +
+

Match Found!

+
+
Name: {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}
+
ID: {{ $matchedVultrInstance['id'] }}
+
Status: {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}
+
Plan: {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}
+
+ + Link This Server + +
+ @endif +
+
+ @endif +
+
+
+
+ @endif @if ($server->isFunctional()) Validate & configure @@ -482,228 +777,6 @@ class="dark:hover:fill-white fill-black dark:fill-warning"> @endif @endif - @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) -
-

Link to Hetzner Cloud

-

- Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. -

- -
-
- - - @foreach ($availableHetznerTokens as $token) - - @endforeach - -
-
- -
- - Search by ID - Searching... - -
OR
- - Search by IP - Searching... - -
- - @if ($hetznerSearchError) -
-

{{ $hetznerSearchError }}

-
- @endif - - @if ($hetznerNoMatchFound) -
-

- @if ($manualHetznerServerId) - No Hetzner server found with ID: {{ $manualHetznerServerId }} - @else - No Hetzner server found matching IP: {{ $server->ip }} - @endif -

-

- Try a different token, enter the Server ID manually, or verify the details are correct. -

-
- @endif - - @if ($matchedHetznerServer) -
-

Match Found!

-
-
Name: {{ $matchedHetznerServer['name'] }}
-
ID: {{ $matchedHetznerServer['id'] }}
-
Status: {{ ucfirst($matchedHetznerServer['status']) }}
-
Type: {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}
-
- - Link This Server - -
- @endif -
- @endif - @if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) -
-

Link to Vultr

-

- Link this server to a Vultr instance to enable power controls and status monitoring. -

- -
-
- - - @foreach ($availableVultrTokens as $token) - - @endforeach - -
-
- -
- - Search by ID - Searching... - -
OR
- - Search by IP - Searching... - -
- - @if ($vultrSearchError) -
-

{{ $vultrSearchError }}

-
- @endif - - @if ($vultrNoMatchFound) -
-

- @if ($manualVultrInstanceId) - No Vultr instance found with ID: {{ $manualVultrInstanceId }} - @else - No Vultr instance found matching IP: {{ $server->ip }} - @endif -

-

- Try a different token, enter the Instance ID manually, or verify the details are correct. -

-
- @endif - - @if ($matchedVultrInstance) -
-

Match Found!

-
-
Name: {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}
-
ID: {{ $matchedVultrInstance['id'] }}
-
Status: {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}
-
Plan: {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}
-
- - Link This Server - -
- @endif -
- @endif - @if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) -
-

Link to DigitalOcean

-

- Link this server to a DigitalOcean droplet to enable power controls and status monitoring. -

- -
-
- - - @foreach ($availableDigitalOceanTokens as $token) - - @endforeach - -
-
- -
- - Search by ID - Searching... - -
OR
- - Search by IP - Searching... - -
- - @if ($digitalOceanSearchError) -
-

{{ $digitalOceanSearchError }}

-
- @endif - - @if ($digitalOceanNoMatchFound) -
-

- @if ($manualDigitalOceanDropletId) - No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} - @else - No DigitalOcean droplet found matching IP: {{ $server->ip }} - @endif -

-
- @endif - - @if ($matchedDigitalOceanDroplet) -
-

Match Found!

-
-
Name: {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}
-
ID: {{ $matchedDigitalOceanDroplet['id'] }}
-
Status: {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}
-
Size: {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}
-
- - Link This Server - -
- @endif -
- @endif diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php new file mode 100644 index 000000000..b05f08173 --- /dev/null +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -0,0 +1,192 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + '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']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'ip' => '1.2.3.4', + ]); +}); + +it('shows link cloud provider dropdown with available unlinked providers', function () { + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Test Hetzner Token', + ]); + + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Test Vultr Token', + ]); + + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'digitalocean', + 'token' => 'test-digitalocean-token', + 'name' => 'Test DigitalOcean Token', + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Link Cloud Provider') + ->assertSee('Hetzner') + ->assertSee('DigitalOcean') + ->assertSee('Vultr') + ->assertSee('Hetzner Token') + ->assertSee('DigitalOcean Token') + ->assertSee('Vultr Token') + ->assertSee('Server ID') + ->assertSee('Droplet ID') + ->assertSee('Instance ID') + ->assertSee('Search by IP') + ->assertSee('Search'); +}); + +it('hides link cloud provider dropdown when no providers can be linked', function () { + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertDontSee('Link Cloud Provider'); +}); + +it('does not list providers already linked to the server', function () { + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Test Hetzner Token', + ]); + + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Test Vultr Token', + ]); + + $this->server->update(['hetzner_server_id' => 123]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Link Cloud Provider') + ->assertSee('Vultr Token') + ->assertDontSee('Hetzner Token'); +}); + +it('shows Hetzner search by IP errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'invalid-hetzner-token', + 'name' => 'Invalid Hetzner Token', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers*' => Http::response([ + 'error' => ['message' => 'invalid token'], + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHetznerTokenId', $token->id) + ->call('searchHetznerServer') + ->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to search Hetzner servers:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to search Hetzner servers:') + ->assertSee('invalid token'); +}); + +it('shows Hetzner search by ID errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'invalid-hetzner-token', + 'name' => 'Invalid Hetzner Token', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers/12345678' => Http::response([ + 'error' => ['message' => 'invalid token'], + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHetznerTokenId', $token->id) + ->set('manualHetznerServerId', '12345678') + ->call('searchHetznerServerById') + ->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to fetch Hetzner server:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to fetch Hetzner server:') + ->assertSee('invalid token'); +}); + +it('shows DigitalOcean search errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'digitalocean', + 'token' => 'invalid-digitalocean-token', + 'name' => 'Invalid DigitalOcean Token', + ]); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets*' => Http::response([ + 'message' => 'invalid token', + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedDigitalOceanTokenId', $token->id) + ->call('searchDigitalOceanDroplet') + ->assertSet('digitalOceanSearchError', fn (string $error) => str_contains($error, 'Failed to search DigitalOcean droplets:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to search DigitalOcean droplets:') + ->assertSee('invalid token'); +}); + +it('shows Vultr search errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'invalid-vultr-token', + 'name' => 'Invalid Vultr Token', + ]); + + Http::fake([ + 'https://api.vultr.com/v2/instances*' => Http::response([ + 'error' => 'invalid token', + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedVultrTokenId', $token->id) + ->call('searchVultrInstance') + ->assertSet('vultrSearchError', fn (string $error) => str_contains($error, 'Failed to search Vultr instances:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to search Vultr instances:') + ->assertSee('invalid token'); +}); diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php index 6152e0c2b..5dfeb31ab 100644 --- a/tests/Feature/Security/PrivateKeyDropdownTest.php +++ b/tests/Feature/Security/PrivateKeyDropdownTest.php @@ -2,8 +2,10 @@ use App\Livewire\Security\PrivateKey\Create; use App\Livewire\Security\PrivateKey\Index; +use App\Livewire\Security\PrivateKey\Show; use App\Models\InstanceSettings; use App\Models\PrivateKey; +use App\Models\Server; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -95,3 +97,38 @@ ->and($view)->toContain('') ->and($badgePosition)->toBeLessThan($saveButtonPosition); }); + +test('used private key details disable delete with an explanation', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + + $this->get(route('security.private-key.show', [ + 'private_key_uuid' => $privateKey->uuid, + ])) + ->assertSuccessful() + ->assertSee('This private key is currently used by a server, application, or Git app and cannot be deleted.', false) + ->assertSee('disabled', false); +}); + +test('used private key delete action keeps the key and shows an error', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + + Livewire::test(Show::class, ['private_key_uuid' => $privateKey->uuid]) + ->call('delete') + ->assertDispatched('error'); + + expect($privateKey->fresh())->not->toBeNull(); +}); diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php new file mode 100644 index 000000000..a0ebcd044 --- /dev/null +++ b/tests/Feature/ServerPrivateKeyDropdownTest.php @@ -0,0 +1,100 @@ +whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); + + Config::set('cache.default', 'array'); + Storage::fake('ssh-keys'); + + $this->currentPrivateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->currentPrivateKey->id, + ]); +}); + +test('server private key page shows highlighted add dropdown actions', function () { + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('+ Add') + ->assertSee('Generate ED25519') + ->assertSee('Generate RSA') + ->assertSee('Add manually') + ->assertSee('Check connection'); +}); + +test('generating a server private key stores it and refreshes the current view', function () { + $component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('generatePrivateKey', 'ed25519') + ->assertNoRedirect() + ->assertDispatched('success'); + + $privateKey = PrivateKey::query() + ->where('id', '!=', $this->currentPrivateKey->id) + ->firstOrFail(); + + expect($privateKey->team_id)->toBe($this->team->id) + ->and($privateKey->public_key)->toStartWith('ssh-ed25519'); + + $component + ->assertDispatched('copyPublicKeyToClipboard', publicKey: $privateKey->public_key) + ->assertSee($privateKey->name); +}); + +test('server private key page copies generated public keys and shows a copied hint', function () { + $view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php')); + + expect($view)->toContain('copyPublicKeyToClipboard') + ->and($view)->toContain('navigator.clipboard.writeText') + ->and($view)->toContain('Public key copied to clipboard.'); +}); + +test('server private key cards include a copy public key button', function () { + $keyData = PrivateKey::generateNewKeyPair('rsa'); + + PrivateKey::createAndStore([ + 'team_id' => $this->team->id, + 'name' => 'Alternative SSH Key', + 'description' => 'Created by test', + 'private_key' => $keyData['private_key'], + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Copy public key') + ->assertSee('Alternative SSH Key'); + + $view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php')); + + expect($view)->toContain('Copy public key') + ->and($view)->toContain('$private_key->public_key') + ->and($view)->toContain('Public key copied to clipboard.'); +});