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.'); +});