mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 10:15:05 +00:00
Merge remote-tracking branch 'origin/next' into 10870-pr-previews-ignore
This commit is contained in:
commit
e325f93662
72 files changed
+3366
-737
No files matched your search
@@ -81,7 +81,7 @@ test('admin sees add server button on dashboard', function () {
|
||||
createServerWithKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('New Server');
|
||||
->assertSee(route('server.create'));
|
||||
});
|
||||
|
||||
test('member does not see add server button on dashboard', function () {
|
||||
@@ -93,5 +93,5 @@ test('member does not see add server button on dashboard', function () {
|
||||
createServerWithKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertDontSee('New Server');
|
||||
->assertDontSee(route('server.create'));
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||
use App\Livewire\Server\Create as ServerCreate;
|
||||
use App\Livewire\Server\Index as ServerIndex;
|
||||
use App\Livewire\Server\Navbar as ServerNavbar;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
@@ -132,6 +135,144 @@ test('member cannot create server', function () {
|
||||
expect(auth()->user()->can('create', Server::class))->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin can access new server page', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create'))
|
||||
->assertSuccessful()
|
||||
->assertSee('New Server')
|
||||
->assertSeeLivewire(ServerCreate::class);
|
||||
});
|
||||
|
||||
test('new server chooser lists providers before rendering a creation form', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerCreate::class)
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('Vultr')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertSee('Manual')
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('Continue')
|
||||
->assertSee(route('server.create.type', ['type' => 'hetzner']))
|
||||
->assertSee(route('server.create.type', ['type' => 'vultr']))
|
||||
->assertSee(route('server.create.type', ['type' => 'digital-ocean']))
|
||||
->assertSee(route('server.create.type', ['type' => 'manual']))
|
||||
->assertDontSee('Add Server by IP Address');
|
||||
});
|
||||
|
||||
test('new server chooser uses compact mobile provider cards', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerCreate::class)
|
||||
->assertSee('mx-auto flex w-full max-w-7xl flex-col', false)
|
||||
->assertSee('sm:grid', false)
|
||||
->assertSee('sm:grid-cols-2', false)
|
||||
->assertSee('xl:grid-cols-4', false)
|
||||
->assertSee('gap-3 sm:gap-6', false)
|
||||
->assertSee('dark:hover:border-warning', false)
|
||||
->assertSee('focus-visible:border-coollabs', false)
|
||||
->assertSee('aria-label="Choose Hetzner"', false)
|
||||
->assertSee('p-3 sm:p-6', false)
|
||||
->assertDontSee('sm:min-h-80', false)
|
||||
->assertSee('size-9 sm:size-14', false)
|
||||
->assertDontSee('size-9 sm:size-14 w-14 h-14', false)
|
||||
->assertSee('hidden sm:block', false)
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('<button', false);
|
||||
});
|
||||
|
||||
test('new server provider pages render the selected creation flow', function (string $type, string $heading) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertSee($heading)
|
||||
->assertDontSee('<h1>New Server</h1>', false)
|
||||
->assertDontSee('Choose how to add your server');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
['manual', 'Manual'],
|
||||
]);
|
||||
|
||||
test('new server provider pages do not show the new token action in the header', function (string $type, string $heading) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('+ New Token')
|
||||
->assertDontSee('+ Add New Token');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
]);
|
||||
|
||||
test('new server provider pages show the new token action in the header when tokens exist', function (string $type, string $provider, string $heading, string $modalTitle) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertSee($modalTitle);
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back', '+ New Token']);
|
||||
})->with([
|
||||
['hetzner', 'hetzner', 'Hetzner', 'Add Hetzner Token'],
|
||||
['vultr', 'vultr', 'Vultr', 'Add Vultr Token'],
|
||||
['digital-ocean', 'digitalocean', 'DigitalOcean', 'Add DigitalOcean Token'],
|
||||
]);
|
||||
|
||||
test('new server manual page does not show the new token action', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create.type', ['type' => 'manual']))
|
||||
->assertSuccessful()
|
||||
->assertSee('Manual')
|
||||
->assertDontSee('+ New Token');
|
||||
});
|
||||
|
||||
test('member cannot access new server page', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('server index links admins to new server page', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerIndex::class)
|
||||
->assertSee(route('server.create'));
|
||||
});
|
||||
|
||||
test('server index does not link members to new server page', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerIndex::class)
|
||||
->assertDontSee(route('server.create'));
|
||||
});
|
||||
|
||||
// --- Cross-team isolation ---
|
||||
|
||||
test('user from different team cannot view server', function () {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\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\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => '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');
|
||||
});
|
||||
@@ -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,36 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
]));
|
||||
|
||||
$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]);
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
||||
Livewire::test(ByDigitalOcean::class)
|
||||
->set('current_step', 2)
|
||||
->assertDontSee('wire:click="previousStep"', false)
|
||||
->assertSeeHtml('class="button w-full"')
|
||||
->assertSee('Buy & Create Server', false);
|
||||
});
|
||||
@@ -82,6 +82,13 @@ function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
|
||||
}
|
||||
|
||||
describe('GitHub Private Repository Component', function () {
|
||||
test('github app selection uses coolbox loading border', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->assertSee('wire:loading.class="coolbox-loading"', false)
|
||||
->assertSee('wire:target="loadRepositories('.$this->githubApp->id.')"', false)
|
||||
->assertDontSee('loading-spinner', false);
|
||||
});
|
||||
|
||||
test('loadRepositories fetches and displays repositories', function () {
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByIp;
|
||||
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\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
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->actingAs($this->user);
|
||||
|
||||
$this->existingPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'Existing SSH Key',
|
||||
'description' => 'Existing SSH Key',
|
||||
'private_key' => 'test-private-key',
|
||||
'team_id' => $this->team->id,
|
||||
]));
|
||||
});
|
||||
|
||||
it('generates and preselects a new private key without clearing server form data', function () {
|
||||
$component = Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->existingPrivateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Production Server')
|
||||
->set('description', 'Deploy target')
|
||||
->set('ip', '192.0.2.50')
|
||||
->set('user', 'deploy.user')
|
||||
->set('port', 2222)
|
||||
->set('is_build_server', true)
|
||||
->call('generatePrivateKey', 'ed25519')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('name', 'Production Server')
|
||||
->assertSet('description', 'Deploy target')
|
||||
->assertSet('ip', '192.0.2.50')
|
||||
->assertSet('user', 'deploy.user')
|
||||
->assertSet('port', 2222)
|
||||
->assertSet('is_build_server', true);
|
||||
|
||||
$newPrivateKeyId = $component->get('private_key_id');
|
||||
|
||||
expect($newPrivateKeyId)->not->toBe($this->existingPrivateKey->id)
|
||||
->and(PrivateKey::find($newPrivateKeyId))->not->toBeNull()
|
||||
->and($component->get('private_keys')->pluck('id'))->toContain($newPrivateKeyId);
|
||||
});
|
||||
|
||||
it('preselects a manually added private key without clearing server form data', function () {
|
||||
$manualPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'Manual SSH Key',
|
||||
'description' => 'Manual SSH Key',
|
||||
'private_key' => 'manual-test-private-key',
|
||||
'team_id' => $this->team->id,
|
||||
]));
|
||||
|
||||
Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->existingPrivateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Production Server')
|
||||
->set('description', 'Deploy target')
|
||||
->set('ip', '192.0.2.51')
|
||||
->set('user', 'deploy.user')
|
||||
->set('port', 2222)
|
||||
->set('is_build_server', true)
|
||||
->call('handlePrivateKeyCreated', $manualPrivateKey->id)
|
||||
->assertSet('private_key_id', $manualPrivateKey->id)
|
||||
->assertSet('name', 'Production Server')
|
||||
->assertSet('description', 'Deploy target')
|
||||
->assertSet('ip', '192.0.2.51')
|
||||
->assertSet('user', 'deploy.user')
|
||||
->assertSet('port', 2222)
|
||||
->assertSet('is_build_server', true)
|
||||
->assertSee('Manual SSH Key');
|
||||
});
|
||||
@@ -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,134 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
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);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -290,10 +290,9 @@ describe('Hetzner data loading', function () {
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->call('nextStep')
|
||||
->assertSet('current_step', 2);
|
||||
$component = Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->call('loadHetznerData');
|
||||
|
||||
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
|
||||
->and($component->get('hetznerNetworks'))->toHaveCount(1);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\CreatePage;
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Livewire\Server\New\ByVultr;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('provider token selection redirects to a token specific server creation url', function (string $component, string $provider, string $type) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test($component)
|
||||
->call('selectToken', $token->id)
|
||||
->assertRedirect(route('server.create.token', [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
]));
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
|
||||
test('provider token selection is rendered as clickable token boxes', function (string $component, string $provider, string $type, string $tokenName) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
'name' => $tokenName,
|
||||
]);
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSee($tokenName)
|
||||
->assertSee(route('server.create.token', ['type' => $type, 'token_uuid' => $token->uuid]))
|
||||
->assertDontSee('loadingProviderTokenId', false)
|
||||
->assertDontSee('coolbox-loading', false)
|
||||
->assertSee('coolbox', false)
|
||||
->assertDontSee('wire:click="selectToken(', false)
|
||||
->assertDontSee('loading-spinner', false)
|
||||
->assertDontSee('Loading Hetzner details...')
|
||||
->assertDontSee('Loading Vultr details...')
|
||||
->assertDontSee('Loading DigitalOcean details...')
|
||||
->assertDontSee('Select a saved token')
|
||||
->assertDontSee('You need a saved')
|
||||
->assertDontSee('Continue');
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Production Hetzner'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Production Vultr'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Production DigitalOcean'],
|
||||
]);
|
||||
|
||||
test('provider token selection shows an add token box when no tokens exist', function (string $component, string $title, string $description) {
|
||||
Livewire::test($component)
|
||||
->assertSee($title)
|
||||
->assertSee($description)
|
||||
->assertSee('max-w-2xl', false)
|
||||
->assertSee('coolbox', false)
|
||||
->assertSee('M12 4.5v15m7.5-7.5h-15', false)
|
||||
->assertDontSee('wire:click="selectToken(', false);
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'Add a new token', 'Add a Hetzner API token to create servers from your account.'],
|
||||
'vultr' => [ByVultr::class, 'Add a new token', 'Add a Vultr API token to create servers from your account.'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'Add a new token', 'Add a DigitalOcean API token to create Droplets from your account.'],
|
||||
]);
|
||||
|
||||
test('provider token urls pass the token uuid into the server creation page', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
expect(route('server.create.token', [
|
||||
'type' => 'hetzner',
|
||||
'token_uuid' => $token->uuid,
|
||||
]))->toEndWith('/servers/new/hetzner/'.$token->uuid);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => 'hetzner',
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertSet('type', 'hetzner')
|
||||
->assertSet('token_uuid', $token->uuid)
|
||||
->assertSet('title', 'Hetzner');
|
||||
});
|
||||
|
||||
test('provider token pages defer provider api data loading until wire init', function (string $component, string $provider, string $type, string $loadingText, string $wireInitCall) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
Livewire::test($component, ['selectedTokenUuid' => $token->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->assertSet('loading_data', true)
|
||||
->assertSee($loadingText)
|
||||
->assertSee($wireInitCall, false)
|
||||
->assertSee('text-coollabs dark:text-warning animate-spin', false)
|
||||
->assertDontSee('border-b-2 border-primary', false);
|
||||
|
||||
Http::assertNothingSent();
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Loading Hetzner data...', 'wire:init="loadHetznerData"'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Loading Vultr data...', 'wire:init="loadVultrData"'],
|
||||
'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,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertSee(route('server.create.type', ['type' => $type]), false)
|
||||
->assertDontSee('href="'.route('server.create').'"', false);
|
||||
})->with([
|
||||
'hetzner' => ['hetzner', 'hetzner'],
|
||||
'vultr' => ['vultr', 'vultr'],
|
||||
'digital-ocean' => ['digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
|
||||
test('new token header button is hidden on token specific provider creation pages', function (string $provider, string $type) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
])
|
||||
->assertSee('+ New Token');
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertDontSee('+ New Token');
|
||||
})->with([
|
||||
'hetzner' => ['hetzner', 'hetzner'],
|
||||
'vultr' => ['vultr', 'vultr'],
|
||||
'digital-ocean' => ['digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\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;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
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);
|
||||
|
||||
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.');
|
||||
});
|
||||
@@ -75,6 +75,19 @@ it('caches parsed local service templates by bundle mtime', function () {
|
||||
expect($first->keys()->all())->toBe($second->keys()->all());
|
||||
});
|
||||
|
||||
it('renders the shared loading indicator while resource choices load', function () {
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
$view = $this->view('livewire.project.new.select', [
|
||||
'current_step' => 'type',
|
||||
'environments' => collect(),
|
||||
]);
|
||||
|
||||
$view->assertSee('Loading resources...', false);
|
||||
$view->assertSee('text-coollabs dark:text-warning animate-spin', false);
|
||||
$view->assertDontSee('<div x-show="loading">Loading...</div>', false);
|
||||
});
|
||||
|
||||
it('renders the service templates last updated hint placeholder', function () {
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
|
||||
@@ -102,9 +102,7 @@ it('creates a Vultr server through the Livewire flow', function () {
|
||||
], 202),
|
||||
]);
|
||||
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('selected_token_id', $this->vultrToken->id)
|
||||
->call('nextStep')
|
||||
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-vultr-server')
|
||||
->set('selected_region', 'ewr')
|
||||
@@ -148,3 +146,22 @@ it('requires IPv6 when public IPv4 is disabled', function () {
|
||||
->call('submit')
|
||||
->assertHasErrors(['enable_ipv6']);
|
||||
});
|
||||
|
||||
it('uses the shared dropdown UI for advanced Vultr options', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Advanced Vultr options')
|
||||
->assertSeeHtml('dropdownOpen')
|
||||
->assertSeeHtml('x-ref="panel"')
|
||||
->assertSee('Additional SSH Keys (from Vultr)')
|
||||
->assertSee('Network Configuration')
|
||||
->assertSee('Cloud-Init Script');
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the Vultr form', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('current_step', 2)
|
||||
->assertDontSee('wire:click="previousStep"', false)
|
||||
->assertSeeHtml('class="button w-full"')
|
||||
->assertSee('Buy & Create Server', false);
|
||||
});
|
||||
@@ -6,11 +6,61 @@ it('allows selecting Vultr in cloud provider token forms', function () {
|
||||
|
||||
expect($view)->toContain('<x-forms.select required id="provider" label="Provider" wire:model.live="provider">')
|
||||
->and($view)->toContain('<option value="vultr">Vultr</option>')
|
||||
->and($view)->toContain('<option disabled value="digitalocean">DigitalOcean</option>')
|
||||
->and($view)->toContain('<option value="digitalocean">DigitalOcean</option>')
|
||||
->and(substr_count($view, 'Open Account → API Access.'))->toBe(2)
|
||||
->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(2)
|
||||
->and($view)->not->toContain('<option value="digitalocean">DigitalOcean</option>')
|
||||
->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()')
|
||||
->and($view)->not->toContain('<x-forms.select required id="provider" label="Provider" disabled>')
|
||||
->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr'");
|
||||
});
|
||||
|
||||
it('keeps provider affiliate links on server provider views', function () {
|
||||
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
|
||||
$hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php');
|
||||
$vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php');
|
||||
$digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php');
|
||||
|
||||
expect($tokenFormView)
|
||||
->not->toContain('https://coolify.io/hetzner')
|
||||
->not->toContain('https://coolify.io/vultr')
|
||||
->and($hetznerView)->toContain('https://coolify.io/hetzner')
|
||||
->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" />');
|
||||
});
|
||||
@@ -33,6 +33,19 @@ it('ensures GlobalSearch uses redirect route helper', function () {
|
||||
->toContain('redirectRoute($this, \'project.resource.create\'');
|
||||
});
|
||||
|
||||
it('routes new server quick action to the new server page', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
expect($globalSearchFile)
|
||||
->toContain("if (\$type === 'server')")
|
||||
->toContain("redirectRoute(\$this, 'server.create')");
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain("window.location.href = '/servers/new'")
|
||||
->not->toContain('@open-create-modal-server.window');
|
||||
});
|
||||
|
||||
it('ensures docker-image item has quickcommand with new image', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
|
||||
@@ -142,3 +155,13 @@ it('uses cropped image assets instead of inline wide logos for GlobalSearch data
|
||||
|
||||
expect($globalSearchFile)->not->toContain("'logo_html' =>");
|
||||
});
|
||||
|
||||
it('uses rounded yellow plus icons for GlobalSearch creatable actions without logos', function () {
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain('rounded-full bg-warning/20 flex items-center justify-center')
|
||||
->toContain('class="h-6 w-6 text-warning"')
|
||||
->not->toContain('rounded-lg bg-warning-100 dark:bg-warning-900/40')
|
||||
->not->toContain('text-warning-600 dark:text-warning-400');
|
||||
});
|
||||
Reference in new issue
Block a user