feat(security): add editable cloud credential pages

Add dedicated show/edit pages for cloud provider tokens and cloud-init
scripts, including descriptions and UUID routes.

Generate private keys directly from the index and surface cloud provider
API loading errors in server creation flows.
This commit is contained in:
Andras Bacsai committed 2026-07-08 12:58:27 +02:00
1 parent 7236cb8228
commit e200d881f5
36 files changed
+1196 -126

No files matched your search

@@ -15,7 +15,11 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
if (! InstanceSettings::query()->whereKey(0)->exists()) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
$this->team = Team::factory()->create();
@@ -0,0 +1,48 @@
<?php
use App\Livewire\Server\CloudProviderToken\Show;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->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);
});
test('server cloud provider token cards show token descriptions', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'hetzner_server_id' => 12345,
]);
CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Production Hetzner',
'description' => 'Used for production servers in the EU region.',
]);
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->assertSee('Production Hetzner')
->assertSee('Used for production servers in the EU region.');
});
@@ -0,0 +1,122 @@
<?php
use App\Livewire\Security\CloudInitScript\Show;
use App\Livewire\Security\CloudInitScriptForm;
use App\Livewire\Security\CloudInitScripts;
use App\Models\CloudInitScript;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
]));
Once::flush();
$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]);
});
test('cloud-init scripts page lists every supported cloud provider integration', function () {
Livewire::test(CloudInitScripts::class)
->assertSee('Hetzner')
->assertSee('Vultr')
->assertSee('DigitalOcean')
->assertDontSee('Currently working only with Hetzner');
});
test('cloud-init script form does not show a cancel button in the modal', function () {
Livewire::test(CloudInitScriptForm::class)
->assertSee('Create Script')
->assertDontSee('Cancel');
});
test('cloud-init script cards link to the script detail page without inline actions or created time', function () {
$script = CloudInitScript::query()->create([
'team_id' => $this->team->id,
'name' => 'Docker Host Setup',
'script' => "#cloud-config\npackages:\n - htop\n",
]);
Livewire::test(CloudInitScripts::class)
->assertSee('Docker Host Setup')
->assertSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false)
->assertDontSee('Created')
->assertDontSee('Edit')
->assertDontSee('Delete');
});
test('cloud-init script detail page shows editable script fields', function () {
$script = CloudInitScript::query()->create([
'team_id' => $this->team->id,
'name' => 'Docker Host Setup',
'script' => "#cloud-config\npackages:\n - htop\n",
]);
$this->get(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]))
->assertSuccessful()
->assertSee('Cloud-Init Script')
->assertSee('Docker Host Setup')
->assertSee('Save')
->assertSee('Delete');
});
test('cloud-init script detail page updates a script', function () {
$script = CloudInitScript::query()->create([
'team_id' => $this->team->id,
'name' => 'Docker Host Setup',
'script' => "#cloud-config\npackages:\n - htop\n",
]);
Livewire::test(Show::class, ['cloud_init_script_uuid' => $script->uuid])
->set('name', 'Updated Host Setup')
->set('script', "#cloud-config\npackages:\n - curl\n")
->call('save')
->assertHasNoErrors()
->assertDispatched('success');
$script->refresh();
expect($script->name)->toBe('Updated Host Setup')
->and($script->script)->toContain('curl');
});
test('cloud-init script detail page deletes a script', function () {
$script = CloudInitScript::query()->create([
'team_id' => $this->team->id,
'name' => 'Docker Host Setup',
'script' => "#cloud-config\npackages:\n - htop\n",
]);
Livewire::test(Show::class, ['cloud_init_script_uuid' => $script->uuid])
->call('delete')
->assertRedirectToRoute('security.cloud-init-scripts');
$this->assertModelMissing($script);
});
test('cloud-init scripts page backfills missing script uuids before rendering links', function () {
$script = CloudInitScript::query()->create([
'team_id' => $this->team->id,
'name' => 'Legacy Script',
'script' => "#cloud-config\npackages:\n - htop\n",
]);
CloudInitScript::query()->whereKey($script->id)->update(['uuid' => null]);
Livewire::test(CloudInitScripts::class)
->assertSee('Legacy Script');
expect($script->refresh()->uuid)->not->toBeNull();
});
@@ -1,6 +1,8 @@
<?php
use App\Livewire\Security\CloudProviderTokenForm;
use App\Livewire\Security\CloudProviderTokens;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
@@ -52,3 +54,36 @@ test('adding a digitalocean token from a modal closes the modal and refreshes di
->and($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded'
&& data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue();
});
test('adding a cloud provider token stores an optional description', function () {
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
Livewire::test(CloudProviderTokenForm::class)
->set('provider', 'hetzner')
->set('name', 'Production Hetzner')
->set('description', 'Used for production servers in the EU region.')
->set('token', 'hetzner-token')
->call('addToken')
->assertHasNoErrors();
$this->assertDatabaseHas('cloud_provider_tokens', [
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Production Hetzner',
'description' => 'Used for production servers in the EU region.',
]);
});
test('saved cloud provider tokens show descriptions when present', function () {
CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'name' => 'Production Hetzner',
'description' => 'Used for production servers in the EU region.',
]);
Livewire::test(CloudProviderTokens::class)
->assertSee('Production Hetzner')
->assertSee('Used for production servers in the EU region.');
});
@@ -0,0 +1,106 @@
<?php
use App\Livewire\Security\CloudProviderToken\Show;
use App\Livewire\Security\CloudProviderTokens;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->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);
});
test('saved cloud token cards link to the token detail page', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'name' => 'Production Hetzner',
]);
Livewire::test(CloudProviderTokens::class)
->assertSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false);
});
test('cloud token detail page shows editable name and description fields', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'name' => 'Production Hetzner',
'description' => 'Used for production servers.',
]);
$this->get(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]))
->assertSuccessful()
->assertSee('Cloud Token')
->assertSee('Production Hetzner')
->assertSee('Used for production servers.')
->assertSee('Validate')
->assertSee('Delete');
});
test('cloud token detail page updates name and description', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'name' => 'Production Hetzner',
'description' => 'Used for production servers.',
]);
Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid])
->set('name', 'Renamed Hetzner')
->set('description', 'Used for staging servers.')
->call('save')
->assertHasNoErrors()
->assertDispatched('success');
$this->assertDatabaseHas('cloud_provider_tokens', [
'id' => $token->id,
'name' => 'Renamed Hetzner',
'description' => 'Used for staging servers.',
]);
});
test('cloud token detail page validates the token', function () {
Http::fake([
'https://api.hetzner.cloud/v1/servers?per_page=1' => Http::response([], 200),
]);
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid])
->call('validateToken')
->assertDispatched('success');
});
test('cloud token detail page deletes unused tokens', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'name' => 'Production Hetzner',
]);
Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid])
->call('delete')
->assertRedirectToRoute('security.cloud-tokens');
$this->assertModelMissing($token);
});
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Security\PrivateKey\Create;
use App\Livewire\Security\PrivateKey\Index;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->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);
Log::spy();
Config::set('cache.default', 'array');
Storage::fake('ssh-keys');
});
test('private key index shows highlighted add dropdown actions', function () {
Livewire::test(Index::class)
->assertSee('+ Add')
->assertSee('Generate ED25519')
->assertSee('Generate RSA')
->assertSee('Add manually')
->assertDontSee('Manage your SSH keys for your servers and integrations.');
});
test('generating a private key from the index stores it and redirects to details', function () {
$component = Livewire::test(Index::class)
->call('generatePrivateKey', 'ed25519');
$privateKey = PrivateKey::query()->firstOrFail();
expect($privateKey->team_id)->toBe($this->team->id)
->and($privateKey->public_key)->toStartWith('ssh-ed25519');
$component->assertRedirect(route('security.private-key.show', [
'private_key_uuid' => $privateKey->uuid,
]));
});
test('manual private key form does not expose key generation controls', function () {
Livewire::test(Create::class)
->assertDontSee('Generate new ED25519 SSH Key')
->assertDontSee('Generate new RSA SSH Key');
});
test('private key details view reminds users to install the public key', function () {
$view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php'));
expect($view)->toContain("ACTION REQUIRED: Copy the 'Public Key' to your server's ~/.ssh/authorized_keys file");
});
test('github app private key shows a highlighted badge under the title', function () {
$privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
'is_git_related' => true,
]);
$this->get(route('security.private-key.show', [
'private_key_uuid' => $privateKey->uuid,
]))
->assertSuccessful()
->assertSee('Used by GitHub App')
->assertDontSee('Is used by a Git App?');
});
test('github app badge appears before the save button in the title row', function () {
$view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php'));
$badgePosition = strpos($view, 'Used by GitHub App');
$saveButtonPosition = strpos($view, '<x-forms.button canGate="update" :canResource="$private_key" type="submit">');
expect($view)->toContain('<div class="flex items-center gap-2 pb-4">')
->and($view)->toContain('Used by GitHub App')
->and($view)->toContain('<x-forms.button canGate="update" :canResource="$private_key" type="submit">')
->and($badgePosition)->toBeLessThan($saveButtonPosition);
});
@@ -129,6 +129,33 @@ test('provider token pages defer provider api data loading until wire init', fun
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Loading DigitalOcean data...', 'wire:init="loadDigitalOceanData"'],
]);
test('provider token pages render api error details when a selected token is invalid', function (string $component, string $provider, string $type, string $loadMethod, string $providerName, string $apiMessage) {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => $provider,
'name' => $providerName.' Token',
]);
Http::fake([
'*' => Http::response($apiMessage, 401),
]);
Livewire::test($component, ['selectedTokenUuid' => $token->uuid])
->call($loadMethod)
->assertSet('loading_data', false)
->assertSet('provider_data_error', "{$providerName} API error: {$apiMessage}")
->assertDispatched('error')
->assertSee("Unable to load {$providerName} details")
->assertSee($apiMessage)
->assertSee('href="'.route('server.create.type', ['type' => $type]).'"', false)
->assertSee('wire:navigate', false)
->assertDontSee('wire:click="previousStep"', false);
})->with([
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'loadHetznerData', 'Hetzner', 'the token you have provided is invalid'],
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'loadVultrData', 'Vultr', 'Invalid API key'],
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'loadDigitalOceanData', 'DigitalOcean', 'Unable to authenticate you'],
]);
test('back button on token specific provider creation pages returns to provider token selection', function (string $provider, string $type) {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
@@ -27,3 +27,40 @@ it('keeps provider affiliate links on server provider views', function () {
->and($vultrView)->toContain('https://coolify.io/vultr')
->and($digitalOceanView)->toContain('https://coolify.io/digitalocean');
});
it('uses a provider dropdown and modal forms on cloud provider tokens page', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php');
expect($view)->toContain('+ Add')
->and($view)->toContain('<div class="flex items-center gap-2">')
->and($view)->toContain('<div class="grid gap-4 lg:grid-cols-2">')
->and($view)->toContain('class="coolbox group"')
->and($view)->toContain('<div class="box-title">')
->and($view)->toContain('<div class="box-description">')
->and($view)->toContain('<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">')
->and($view)->toContain('Add Hetzner Token')
->and($view)->toContain('Add DigitalOcean Token')
->and($view)->toContain('Add Vultr Token')
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"')
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"')
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"')
->and($view)->not->toContain('<h3>New Token</h3>')
->and($view)->not->toContain(':modal_mode="false"')
->and($view)->not->toContain('wire:click="validateToken')
->and($view)->not->toContain('submitAction="deleteToken')
->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}');
});
it('shows explicit loading spinners and disables cloud token action buttons while requests run', function () {
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
$tokenShowView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token/show.blade.php');
$serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php');
expect(substr_count($tokenFormView, 'wire:loading.attr="disabled" wire:target="addToken"'))->toBe(2)
->and(substr_count($tokenFormView, '<x-loading-on-button wire:loading wire:target="addToken" />'))->toBe(2)
->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"')
->and($tokenShowView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />')
->and($serverTokenView)->toContain('wire:loading.attr="disabled"')
->and($serverTokenView)->toContain('wire:target="validateToken"')
->and($serverTokenView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />');
});