Merge remote-tracking branch 'origin/next' into 10870-pr-previews-ignore

This commit is contained in:
Andras Bacsai committed 2026-07-08 15:06:16 +02:00
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);
+20 -3
View File
@@ -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);
});
+52 -2
View File
@@ -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');
});