coolify/tests/Feature/V5/DashboardTest.php

2183 lines
86 KiB
PHP
Raw Normal View History

<?php
use App\Http\Controllers\V5\DashboardController;
use App\Http\Kernel;
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\V5\EnsureCurrentTeam;
use App\Http\Middleware\V5\HandleInertiaRequests;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxHealth;
use Database\Seeders\V5DevLimaSeeder;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Mockery\MockInterface;
beforeEach(function () {
Config::set('app.maintenance.store', 'array');
Config::set('cache.default', 'array');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
Schema::dropIfExists('private_keys');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
Schema::dropIfExists('users');
});
it('registers the v5 dashboard route', function () {
expect(Route::has('v5.dashboard'))->toBeTrue()
->and(Route::has('v5.home'))->toBeFalse()
->and(Route::has('v5.selection.update'))->toBeTrue()
->and(Route::has('v5.clusters.index'))->toBeTrue()
->and(Route::has('v5.clusters.store'))->toBeTrue()
->and(Route::has('v5.clusters.destroy'))->toBeTrue()
->and(Route::has('v5.clusters.servers.store'))->toBeTrue()
->and(Route::has('v5.clusters.servers.update'))->toBeTrue()
->and(Route::has('v5.clusters.servers.check'))->toBeTrue()
->and(Route::has('v5.clusters.servers.bootstrap'))->toBeTrue()
->and(Route::has('v5.clusters.servers.destroy'))->toBeTrue()
->and(Route::has('v5.coolify.version'))->toBeFalse()
->and(Route::has('v5.coolify.bootstrap'))->toBeFalse();
});
it('uses separated v5 middleware groups', function () {
$kernel = app(Kernel::class);
$reflection = new ReflectionClass($kernel);
$property = $reflection->getProperty('middlewareGroups');
$property->setAccessible(true);
$groups = $property->getValue($kernel);
expect($groups)->toHaveKey('v5.web')
->and($groups)->toHaveKey('v5.authenticated')
->and($groups['v5.web'])->toContain(HandleInertiaRequests::class)
->and($groups['v5.web'])->not->toContain(CheckForcePasswordReset::class)
->and($groups['v5.web'])->not->toContain(DecideWhatToDoWithUser::class)
->and($groups['v5.authenticated'])->toContain('auth')
->and($groups['v5.authenticated'])->toContain('verified')
->and($groups['v5.authenticated'])->toContain(EnsureCurrentTeam::class);
});
it('reuses existing projects instead of creating v5 projects', function () {
expect(file_exists(database_path('migrations/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse()
->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse();
});
it('creates v5 cluster tables and lets each server belong to one cluster', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$configurationMigration = include database_path('migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php');
$configurationMigration->up();
$statusCheckMigration = include database_path('migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php');
$statusCheckMigration->up();
expect(Schema::hasTable('v5_clusters'))->toBeTrue()
->and(Schema::hasColumns('v5_clusters', [
'id',
'team_id',
'created_by_user_id',
'name',
'description',
'wireguard_interface',
'wireguard_management_pool',
'wireguard_listen_port',
'container_network_pool',
'container_network_prefix',
'namespaces',
'default_deny_containers',
'coold_version',
'corrosion_version',
'corrosion_gossip_port',
'corrosion_api_port',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'builder_memory_max',
'builder_timeout_secs',
'last_cli_action',
'last_cli_status',
'last_cli_summary',
'last_cli_ran_at',
'created_at',
'updated_at',
]))->toBeTrue()
->and(Schema::hasColumn('v5_servers', 'cluster_id'))->toBeTrue();
});
it('creates v5 server tables in the shared database', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$migration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$migration->up();
$configurationMigration = include database_path('migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php');
$configurationMigration->up();
$statusCheckMigration = include database_path('migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php');
$statusCheckMigration->up();
expect(Schema::hasTable('v5_servers'))->toBeTrue()
->and(Schema::hasColumns('v5_servers', [
'id',
'team_id',
'cluster_id',
'created_by_user_id',
'private_key_id',
'name',
'host',
'ssh_user',
'ssh_port',
'status',
'capabilities',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'node_address',
'wireguard_listen_port_override',
'wireguard_endpoint_override',
'wireguard_management_ip',
'wireguard_public_key',
'container_subnets',
'last_bootstrapped_at',
'last_status_check',
'last_status_output',
'last_status_checked_at',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('adds builder cpu quota to existing v5 server tables safely', function () {
Schema::dropIfExists('v5_servers');
Schema::create('v5_servers', function ($table) {
$table->id();
$table->unsignedInteger('builder_capacity')->default(0);
});
$migration = include database_path('migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php');
$migration->up();
$migration->up();
expect(Schema::hasColumn('v5_servers', 'builder_cpu_quota'))->toBeTrue();
$migration->down();
$migration->down();
expect(Schema::hasColumn('v5_servers', 'builder_cpu_quota'))->toBeFalse();
});
it('includes v5 tables in the dev testing schema', function () {
$schema = file_get_contents(database_path('schema/testing-schema.sql'));
expect($schema)->toContain('"team_id" INTEGER NOT NULL')
->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL')
->and($schema)->not->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
->and($schema)->not->toContain('2026_06_04_050157_v5_create_projects_table')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"')
->and($schema)->toContain('"cluster_id" INTEGER')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"')
->and($schema)->toContain('"wireguard_interface" TEXT DEFAULT \'wg0\' NOT NULL')
->and($schema)->toContain('"wireguard_management_pool" TEXT DEFAULT \'100.64.0.0/16\' NOT NULL')
->and($schema)->toContain('"container_network_pool" TEXT DEFAULT \'10.210.0.0/16\' NOT NULL')
->and($schema)->toContain('"builder_timeout_secs" INTEGER NOT NULL DEFAULT \'1800\'')
->and($schema)->toContain('"private_key_id" INTEGER')
->and($schema)->toContain('"builder_cpu_quota" TEXT DEFAULT \'200%\' NOT NULL')
->and($schema)->toContain('"wireguard_management_ip" TEXT')
->and($schema)->toContain('"container_subnets" JSON')
->and($schema)->toContain('"last_status_output" TEXT')
->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table')
->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table')
->and($schema)->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers')
->and($schema)->toContain('2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table')
->and($schema)->toContain('2026_06_17_172845_add_status_check_fields_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_16_132000_make_v5_server_private_key_nullable')
->and($schema)->not->toContain('v5_hosts');
});
it('redirects guests to the shared login', function () {
$this->get('/v5')
->assertRedirect('/login');
});
it('serves the v5 inertia shell', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'V5 Shared Team',
'description' => 'Shared team details',
'personal_team' => true,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('<html lang="en" class="dark">', false)
->assertSee('coolify-logo-dev-transparent.png', false)
->assertDontSee('coolify-logo.svg', false)
->assertSee('v5-app', false)
->assertSee('Dashboard', false)
->assertDontSee('Home', false)
->assertDontSee('v5-ready', false)
->assertDontSee('This page is served from Laravel through Inertia and React')
->assertDontSee('Bootstrap server')
->assertDontSee('privateKeys', false)
->assertSee('Running')
->assertSee('Flux is running.')
->assertDontSee('"clusters":', false)
->assertDontSee('cooldServers', false)
->assertDontSee('coold-dev')
->assertDontSee('Create cluster')
->assertDontSee('Cluster details')
->assertDontSee('100.64.0.1')
->assertDontSee('Current team')
->assertDontSee('Your teams')
->assertDontSee('currentTeam', false)
->assertDontSee('teams', false)
->assertDontSee('V5 Shared Team')
->assertDontSee('Shared team details');
});
it('serves the production v5 favicon outside local environments', function () {
app()->detectEnvironment(fn () => 'production');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('coolify-logo.svg', false)
->assertDontSee('coolify-logo-dev-transparent.png', false);
});
it('shows v5 clusters with their servers on the cluster page', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/clusters')
->assertSuccessful()
->assertSee('Clusters', false)
->assertSee('"clusters":[', false)
->assertSee('"privateKeys":[', false)
->assertSee('"name":"Lima Key"', false)
->assertSee('"name":"Development-Lima"', false)
->assertSee('"serversCount":1', false)
->assertSee('"name":"coold-dev"', false)
->assertSee('"host":"lima-coold-dev"', false)
->assertDontSee('"sshUser"', false)
->assertDontSee('"sshPort"', false)
->assertSee('"builderEnabled":true', false)
->assertSee('"builderCapacity":2', false)
->assertSee('"wireguardInterface":"wg0"', false)
->assertSee('"wireguardManagementPool":"100.64.0.0\\/16"', false)
->assertSee('"containerNetworkPool":"10.210.0.0\\/16"', false)
->assertSee('"namespaces":["default"]', false)
->assertSee('"defaultDenyContainers":true', false)
->assertSee('"cooldVersion":"nightly"', false)
->assertSee('"corrosionVersion":"v1.0.0"', false)
->assertSee('"builderCpuQuota":"200%"', false)
->assertSee('"builderMemoryMax":"2G"', false)
->assertSee('"builderTimeoutSecs":1800', false)
->assertSee('"lastCliStatus":null', false)
->assertSee('"privateKeyName":"Lima Key"', false)
->assertSee('"nodeAddress":null', false)
->assertSee('"wireguardManagementIp":null', false)
->assertSee('"containerSubnets":[]', false)
->assertSee('"lastBootstrappedAt":"', false);
});
it('creates a v5 cluster for the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Primary production cluster.',
])
->assertCreated()
->assertJsonPath('cluster.name', 'Production Mesh')
->assertJsonPath('cluster.description', 'Primary production cluster.')
->assertJsonPath('cluster.wireguardInterface', 'wg0')
->assertJsonPath('cluster.wireguardManagementPool', '100.64.0.0/16')
->assertJsonPath('cluster.wireguardListenPort', 51820)
->assertJsonPath('cluster.containerNetworkPool', '10.210.0.0/16')
->assertJsonPath('cluster.containerNetworkPrefix', 24)
->assertJsonPath('cluster.namespaces', ['default'])
->assertJsonPath('cluster.defaultDenyContainers', true)
->assertJsonPath('cluster.cooldVersion', 'nightly')
->assertJsonPath('cluster.corrosionVersion', 'v1.0.0')
->assertJsonPath('cluster.corrosionGossipPort', 8787)
->assertJsonPath('cluster.corrosionApiPort', 8080)
->assertJsonPath('cluster.builderEnabled', true)
->assertJsonPath('cluster.builderCapacity', 2)
->assertJsonPath('cluster.builderCpuQuota', '200%')
->assertJsonPath('cluster.builderMemoryMax', '2G')
->assertJsonPath('cluster.builderTimeoutSecs', 1800)
->assertJsonPath('cluster.lastCliAction', null)
->assertJsonPath('cluster.lastCliStatus', null)
->assertJsonPath('cluster.lastCliSummary', null)
->assertJsonPath('cluster.lastCliRanAt', null)
->assertJsonPath('cluster.serversCount', 0)
->assertJsonPath('cluster.servers', []);
expect(Cluster::query()
->where('team_id', $team->id)
->where('created_by_user_id', $user->id)
->where('name', 'Production Mesh')
->where('description', 'Primary production cluster.')
->where('wireguard_interface', 'wg0')
->where('wireguard_management_pool', '100.64.0.0/16')
->where('container_network_pool', '10.210.0.0/16')
->exists())->toBeTrue();
});
it('creates a v5 cluster with advanced cli configuration', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Custom Mesh',
'description' => null,
'wireguard_interface' => 'wg-prod',
'wireguard_management_pool' => '100.65.0.0/16',
'wireguard_listen_port' => 51830,
'container_network_pool' => '10.211.0.0/16',
'container_network_prefix' => 25,
'namespaces' => ['default', 'preview'],
'default_deny_containers' => false,
'coold_version' => 'v0.2.0',
'corrosion_version' => 'v1.1.0',
'corrosion_gossip_port' => 8788,
'corrosion_api_port' => 8081,
'builder_enabled' => true,
'builder_capacity' => 4,
'builder_cpu_quota' => '400%',
'builder_memory_max' => '4G',
'builder_timeout_secs' => 2400,
])
->assertCreated()
->assertJsonPath('cluster.wireguardInterface', 'wg-prod')
->assertJsonPath('cluster.wireguardManagementPool', '100.65.0.0/16')
->assertJsonPath('cluster.wireguardListenPort', 51830)
->assertJsonPath('cluster.containerNetworkPool', '10.211.0.0/16')
->assertJsonPath('cluster.containerNetworkPrefix', 25)
->assertJsonPath('cluster.namespaces', ['default', 'preview'])
->assertJsonPath('cluster.defaultDenyContainers', false)
->assertJsonPath('cluster.cooldVersion', 'v0.2.0')
->assertJsonPath('cluster.corrosionVersion', 'v1.1.0')
->assertJsonPath('cluster.builderCapacity', 4)
->assertJsonPath('cluster.builderCpuQuota', '400%')
->assertJsonPath('cluster.builderMemoryMax', '4G')
->assertJsonPath('cluster.builderTimeoutSecs', 2400);
});
it('validates advanced v5 cluster cli configuration', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Broken Mesh',
'wireguard_management_pool' => 'not-a-cidr',
'container_network_pool' => '10.211.0.0',
'wireguard_listen_port' => 70000,
'namespaces' => ['Default'],
])
->assertUnprocessable()
->assertJsonValidationErrors([
'wireguard_management_pool',
'container_network_pool',
'wireguard_listen_port',
'namespaces.0',
]);
});
it('adds a v5 server to a cluster for the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
'builder_enabled' => true,
'builder_capacity' => 3,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'private_key_id' => $privateKey->id,
'node_address' => '203.0.113.10',
'builder_enabled' => true,
'builder_capacity' => 3,
'wireguard_listen_port_override' => 51821,
'wireguard_endpoint_override' => 'prod-01.example.com:51821',
])
->assertCreated()
->assertJsonPath('cluster.serversCount', 1)
->assertJsonPath('cluster.servers.0.name', 'prod-01')
->assertJsonPath('cluster.servers.0.host', '203.0.113.10')
->assertJsonMissingPath('cluster.servers.0.sshUser')
->assertJsonMissingPath('cluster.servers.0.sshPort')
->assertJsonPath('cluster.servers.0.privateKeyName', 'Production SSH Key')
->assertJsonPath('cluster.servers.0.nodeAddress', '203.0.113.10')
->assertJsonPath('cluster.servers.0.builderEnabled', true)
->assertJsonPath('cluster.servers.0.builderCapacity', 3)
->assertJsonPath('cluster.servers.0.builderCpuQuota', '200%')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'builder'])
->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51821)
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821')
->assertJsonPath('cluster.servers.0.wireguardManagementIp', null)
->assertJsonPath('cluster.servers.0.containerSubnets', []);
expect(V5Server::query()
->where('team_id', $team->id)
->where('cluster_id', $cluster->id)
->where('created_by_user_id', $user->id)
->where('name', 'prod-01')
->where('host', '203.0.113.10')
->where('ssh_user', 'root')
->where('ssh_port', 22)
->where('private_key_id', $privateKey->id)
->where('node_address', '203.0.113.10')
->exists())->toBeTrue();
expect(V5Server::query()->where('name', 'prod-01')->first()->capabilities)
->toBe(['coold', 'builder']);
});
it('defaults dev Lima wireguard overrides for host docker internal servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Testing Host Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'coolify-naked-test',
'host' => 'host.docker.internal',
'ssh_user' => 'root',
'ssh_port' => 60003,
'private_key_id' => $privateKey->id,
])
->assertCreated()
->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51823)
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'host.lima.internal:51823');
});
it('rejects adding a v5 server to another teams cluster', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$cluster = Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $user->id,
'name' => 'Other Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
])
->assertForbidden();
});
it('validates v5 server creation input', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => '',
'host' => '',
'ssh_user' => '',
'ssh_port' => 70000,
'private_key_id' => null,
'wireguard_listen_port_override' => 70000,
])
->assertUnprocessable()
->assertJsonValidationErrors([
'name',
'host',
'ssh_user',
'ssh_port',
'private_key_id',
'wireguard_listen_port_override',
]);
});
it('rejects private keys from another team when adding a v5 server', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherPrivateKey = createV5PrivateKey($otherTeam, 'Other SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'private_key_id' => $otherPrivateKey->id,
])
->assertUnprocessable()
->assertJsonValidationErrors(['private_key_id']);
});
it('checks v5 server ssh status and stores diagnostic output', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: "SSH connection OK\nprod-01\nLinux aarch64\n/usr/bin/docker\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/check")
->assertSuccessful()
->assertJsonPath('cluster.servers.0.status', 'pending')
->assertJsonPath('cluster.servers.0.lastStatusCheck', 'reachable')
->assertJsonPath('cluster.servers.0.lastStatusOutput', "SSH connection OK\nprod-01\nLinux aarch64\n/usr/bin/docker");
Process::assertRan(fn ($process) => str_contains(json_encode($process->command), '203.0.113.10'));
Process::assertRan(fn ($process) => in_array('LogLevel=ERROR', $process->command, true));
$server->refresh();
expect($server->status)->toBe('pending')
->and($server->last_status_check)->toBe('reachable')
->and($server->last_status_output)->toContain('SSH connection OK')
->and($server->last_status_checked_at)->not->toBeNull();
});
it('writes quiet ssh options for v5 bootstrap ssh config', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
]);
$server->load('privateKey');
$tempDirectory = storage_path('framework/testing/v5_bootstrap_'.str()->random(8));
mkdir($tempDirectory, 0700, true);
try {
$controller = app(DashboardController::class);
$reflection = new ReflectionClass($controller);
$method = $reflection->getMethod('writeBootstrapSshConfig');
$method->setAccessible(true);
$sshConfigLocation = $method->invoke($controller, collect([$server]), $tempDirectory);
$config = file_get_contents($sshConfigLocation);
expect($config)->toContain(' LogLevel ERROR')
->and($config)->toContain(' UserKnownHostsFile /dev/null')
->and($config)->toContain(' StrictHostKeyChecking no');
} finally {
collect(scandir($tempDirectory) ?: [])
->reject(fn (string $file) => in_array($file, ['.', '..'], true))
->each(fn (string $file) => @unlink($tempDirectory.'/'.$file));
@rmdir($tempDirectory);
}
});
it('bootstraps a single v5 server with the Coolify CLI', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
'namespaces' => ['default', 'preview'],
'container_network_pool' => '10.211.0.0/16',
'container_network_prefix' => 25,
'wireguard_management_pool' => '100.65.0.0/16',
'wireguard_interface' => 'wg-prod',
'wireguard_listen_port' => 51830,
'coold_version' => 'v0.2.0',
'corrosion_version' => 'v1.1.0',
'corrosion_gossip_port' => 8788,
'corrosion_api_port' => 8081,
'builder_enabled' => true,
'builder_capacity' => 4,
'builder_cpu_quota' => '400%',
'builder_memory_max' => '4G',
'builder_timeout_secs' => 2400,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 2222,
'status' => 'pending',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 4,
'wireguard_listen_port_override' => 51831,
'wireguard_endpoint_override' => 'prod-01.example.com:51831',
]);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertSuccessful()
->assertJsonPath('cluster.lastCliAction', 'bootstrap')
->assertJsonPath('cluster.lastCliStatus', 'succeeded')
->assertJsonPath('cluster.lastCliSummary', 'Bootstrap completed')
->assertJsonPath('cluster.servers.0.status', 'installed')
->assertJsonPath('cluster.servers.0.capabilities', ['builder', 'coold'])
->assertJsonPath('cluster.servers.0.lastBootstrappedAt', fn (?string $value) => $value !== null);
Process::assertRan(function ($process) use ($server): bool {
$command = $process->command;
$node = "v5-server-{$server->id}";
return $command[0] === '/tmp/coolify'
&& in_array('init', $command, true)
&& in_array('bootstrap', $command, true)
&& in_array($node, $command, true)
&& in_array('--ssh-config', $command, true)
&& in_array('default,preview', $command, true)
&& in_array('10.211.0.0/16', $command, true)
&& in_array('100.65.0.0/16', $command, true)
&& in_array('wg-prod', $command, true)
&& in_array('v0.2.0', $command, true)
&& in_array('v1.1.0', $command, true)
&& in_array("{$node}=51831", $command, true)
&& in_array("{$node}=prod-01.example.com:51831", $command, true)
&& in_array('--enable-builder', $command, true)
&& in_array('--yes', $command, true)
&& ! in_array('--new-nodes', $command, true);
});
$cluster->refresh();
$server->refresh();
expect($cluster->last_cli_status)->toBe('succeeded')
->and($cluster->last_cli_summary)->toBe('Bootstrap completed')
->and($server->status)->toBe('installed')
->and($server->last_bootstrapped_at)->not->toBeNull();
});
it('does not run a macOS development Coolify CLI binary from Docker during v5 server bootstrap', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/usr/local/bin/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertSuccessful();
Process::assertRan(fn ($process): bool => $process->command[0] === '/usr/local/bin/coolify');
});
it('keeps a v5 server pending when Coolify CLI bootstrap fails', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(errorOutput: "bootstrap failed\n", exitCode: 1),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertServerError()
->assertJsonPath('message', 'bootstrap failed')
->assertJsonPath('cluster.lastCliAction', 'bootstrap')
->assertJsonPath('cluster.lastCliStatus', 'failed')
->assertJsonPath('cluster.lastCliSummary', 'bootstrap failed')
->assertJsonPath('cluster.servers.0.status', 'pending')
->assertJsonPath('cluster.servers.0.lastBootstrappedAt', null);
$server->refresh();
expect($server->status)->toBe('pending')
->and($server->last_bootstrapped_at)->toBeNull();
});
it('extends a v5 cluster when bootstrapping a new server', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$oldPrivateKey = createV5PrivateKey($team, 'Old SSH Key');
$newPrivateKey = createV5PrivateKey($team, 'New SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$oldServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $oldPrivateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'status' => 'installed',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrapped_at' => now()->subDay(),
]);
$newServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $newPrivateKey->id,
'name' => 'prod-02',
'host' => '203.0.113.11',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: "Extend completed\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$newServer->id}/bootstrap")
->assertSuccessful()
->assertJsonPath('cluster.lastCliAction', 'extend')
->assertJsonPath('cluster.lastCliStatus', 'succeeded')
->assertJsonPath('cluster.lastCliSummary', 'Extend completed')
->assertJsonPath('cluster.servers.0.status', 'installed')
->assertJsonPath('cluster.servers.1.status', 'installed');
Process::assertRan(function ($process) use ($oldServer, $newServer): bool {
$command = $process->command;
$oldNode = "v5-server-{$oldServer->id}";
$newNode = "v5-server-{$newServer->id}";
return in_array('extend', $command, true)
&& in_array("{$oldNode},{$newNode}", $command, true)
&& in_array('--new-nodes', $command, true)
&& in_array($newNode, $command, true)
&& in_array('--ssh-config', $command, true);
});
$oldServer->refresh();
$newServer->refresh();
expect($oldServer->status)->toBe('installed')
->and($newServer->status)->toBe('installed')
->and($newServer->last_bootstrapped_at)->not->toBeNull();
});
it('deletes an unbootstrapped v5 server from a cluster', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson("/v5/clusters/{$cluster->id}/servers/{$server->id}")
->assertSuccessful()
->assertJsonPath('cluster.serversCount', 0)
->assertJsonPath('cluster.servers', []);
expect(V5Server::query()->whereKey($server->id)->exists())->toBeFalse();
});
it('does not delete a bootstrapped v5 server', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrapped_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson("/v5/clusters/{$cluster->id}/servers/{$server->id}")
->assertConflict()
->assertJsonPath('message', 'Only unbootstrapped servers can be deleted.');
expect(V5Server::query()->whereKey($server->id)->exists())->toBeTrue();
});
it('updates editable v5 server builder details without changing networking', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '100%',
'node_address' => '10.0.0.10',
'wireguard_listen_port_override' => 51821,
'wireguard_endpoint_override' => 'prod-01.example.com:51821',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => true,
'builder_capacity' => 5,
'builder_cpu_quota' => '350%',
'host' => '198.51.100.99',
'ssh_user' => 'admin',
'ssh_port' => 2222,
'node_address' => '10.0.0.99',
'wireguard_endpoint_override' => 'changed.example.com:51821',
])
->assertSuccessful()
->assertJsonPath('cluster.servers.0.builderEnabled', true)
->assertJsonPath('cluster.servers.0.builderCapacity', 5)
->assertJsonPath('cluster.servers.0.builderCpuQuota', '350%')
->assertJsonPath('cluster.servers.0.host', '203.0.113.10')
->assertJsonMissingPath('cluster.servers.0.sshUser')
->assertJsonMissingPath('cluster.servers.0.sshPort')
->assertJsonPath('cluster.servers.0.nodeAddress', '10.0.0.10')
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821');
$server->refresh();
expect($server->builder_enabled)->toBeTrue()
->and($server->builder_capacity)->toBe(5)
->and($server->builder_cpu_quota)->toBe('350%')
->and($server->capabilities)->toBe(['coold', 'builder'])
->and($server->host)->toBe('203.0.113.10')
->and($server->ssh_user)->toBe('root')
->and($server->ssh_port)->toBe(22)
->and($server->node_address)->toBe('10.0.0.10')
->and($server->wireguard_endpoint_override)->toBe('prod-01.example.com:51821');
});
it('validates editable v5 server builder details', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'builder_cpu_quota' => '200%',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => true,
'builder_capacity' => 1001,
'builder_cpu_quota' => str_repeat('a', 33),
])
->assertUnprocessable()
->assertJsonValidationErrors(['builder_capacity', 'builder_cpu_quota']);
});
it('validates v5 cluster creation input', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => '',
'description' => str_repeat('a', 1001),
])
->assertUnprocessable()
->assertJsonValidationErrors(['name', 'description']);
});
it('rejects duplicate v5 cluster names in the same team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => null,
])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
it('deletes an empty v5 cluster in the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Empty Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->id)
->assertNoContent();
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeFalse();
});
it('does not delete a v5 cluster that has servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => null,
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->id)
->assertUnprocessable()
->assertJsonPath('message', 'Only empty clusters can be deleted.');
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
});
it('does not delete a v5 cluster outside the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherUser = User::withoutEvents(fn () => User::query()->create([
'name' => 'Other User',
'email' => 'other-delete@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$cluster = Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $otherUser->id,
'name' => 'Other Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->id)
->assertNotFound();
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
});
it('allows the same v5 cluster name in another team without leaking it', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherUser = User::withoutEvents(fn () => User::query()->create([
'name' => 'Other User',
'email' => 'other@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $otherUser->id,
'name' => 'Production Mesh',
'description' => 'Other team cluster.',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Current team cluster.',
])
->assertCreated()
->assertJsonPath('cluster.description', 'Current team cluster.');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/clusters')
->assertSuccessful()
->assertSee('Current team cluster.')
->assertDontSee('Other team cluster.');
});
it('shares existing projects and environments with the v5 dashboard page', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'gravy-truck', 'production');
createV5ProjectWithEnvironment($team, 'rocket-bike', 'staging');
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"projects":[', false)
->assertSee('"name":"gravy-truck"', false)
->assertSee('"uuid":"'.$project->uuid.'"', false)
->assertSee('"environments":[', false)
->assertSee('"name":"production"', false)
->assertSee('"uuid":"'.$environment->uuid.'"', false)
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
->assertDontSee('"id":"'.$project->id.'","uuid":"'.$project->uuid.'"', false)
->assertDontSee('"id":"'.$environment->id.'","uuid":"'.$environment->uuid.'"', false)
->assertDontSee($otherProject->name)
->assertDontSee($otherProject->uuid)
->assertDontSee($otherEnvironment->name)
->assertDontSee($otherEnvironment->uuid);
});
it('persists the selected v5 project and environment in the session', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5ProjectWithEnvironment($team, 'alpha-project', 'production');
[$selectedProject, $selectedEnvironment] = createV5ProjectWithEnvironment($team, 'zebra-project', 'staging');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/selection', [
'project_uuid' => $selectedProject->uuid,
'environment_uuid' => $selectedEnvironment->uuid,
])
->assertNoContent()
->assertSessionHas('v5.selectedProjectUuid', $selectedProject->uuid)
->assertSessionHas('v5.selectedEnvironmentUuid', $selectedEnvironment->uuid);
$this
->actingAs($user)
->get('/v5')
->assertSuccessful()
->assertSee('"selectedProjectUuid":"'.$selectedProject->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$selectedEnvironment->uuid.'"', false);
});
it('rejects persisted v5 selections outside the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/selection', [
'project_uuid' => $otherProject->uuid,
'environment_uuid' => $otherEnvironment->uuid,
])
->assertForbidden()
->assertSessionMissing('v5.selectedProjectUuid')
->assertSessionMissing('v5.selectedEnvironmentUuid');
});
it('defines the v5 dashboard page as a shadcn styled canvas shell', function () {
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$app = file_get_contents(resource_path('js/v5/app.tsx'));
$navbarPath = resource_path('js/v5/components/app-navbar.tsx');
expect(file_exists($navbarPath))->toBeTrue();
$navbar = file_get_contents($navbarPath);
$sheetPath = resource_path('js/v5/components/ui/sheet.tsx');
expect(file_exists($sheetPath))->toBeTrue();
expect($app)
->toContain('progress: {')
->toContain('delay: 10')
->toContain("color: '#fcd452'")
->toContain('showSpinner: false')
->not->toContain('TopNavigationLoadingIndicator')
->not->toContain('withApp:');
expect($dashboardPage)
->toContain('Dashboard')
->toContain("import { AppNavbar } from '@/components/app-navbar';")
->not->toContain('function csrfToken()')
->not->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain("import { Button } from '@/components/ui/button';")
->not->toContain("fetch('/v5/clusters'")
->toContain('<AppNavbar')
->toContain('bg-background text-foreground')
->toContain('h-dvh overflow-hidden bg-background text-foreground')
->toContain('flex h-full min-h-0 items-center justify-center overflow-hidden px-6 pt-16')
->not->toContain('<header')
->not->toContain('h-[calc(100dvh-4rem)]')
->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground')
->toContain('This is where the magic happens.')
->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
->not->toContain("fetch('/v5/selection'");
expect($navbar)
->toContain("import { Link, usePage } from '@inertiajs/react';")
->toContain("import { cn } from '@/lib/utils';")
->toContain("import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain('function csrfToken()')
->toContain('export function AppNavbar')
->toContain('/coolify-logo.svg')
->toContain('<Link')
->toContain('className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background"')
->not->toContain('className="sticky top-0 z-40 shrink-0 border-b border-border bg-background"')
->toContain('bg-muted/40')
->toContain('text-muted-foreground')
->toContain('SelectGroup')
->toContain('variant="ghost"')
->toContain('position="popper"')
->toContain('sideOffset={4}')
->toContain('Select a project')
->toContain('Select an environment')
->toContain('const { url } = usePage();')
->toContain("const isClustersPage = url.startsWith('/v5/clusters');")
->toContain('href="/v5"')
->toContain('href="/v5/clusters"')
->toContain('Clusters')
->toContain('className="relative flex h-16 items-center gap-3 px-4 sm:px-6"')
->toContain('className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2"')
->toContain('className="max-w-[38vw] md:max-w-[10rem]"')
->toContain('className="max-w-[30vw] md:max-w-[10rem]"')
->toContain('aria-label="Open mobile menu"')
->toContain('<Sheet>')
->toContain('<SheetTrigger')
->toContain('<SheetContent side="right" className="w-72 max-w-[85vw] bg-background"')
->toContain('<SheetHeader>')
->toContain('<SheetTitle>Coolify</SheetTitle>')
->toContain('<SheetDescription className="sr-only">')
->toContain('<SheetClose')
->toContain('Move between Coolify v5 pages.')
->not->toContain('<SheetTitle className="sr-only">Navigation</SheetTitle>')
->toContain('className="hidden rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:inline-flex"')
->toContain('className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"')
->toContain('Dashboard')
->not->toContain('Home')
->toContain("fetch('/v5/selection'")
->toContain("'X-CSRF-TOKEN': csrfToken()")
->not->toContain('isMobileMenuOpen')
->not->toContain('setIsMobileMenuOpen')
->not->toContain('isProjectEnvironmentMenuOpen')
->not->toContain('setIsProjectEnvironmentMenuOpen')
->not->toContain('Open project and environment selector')
->not->toContain('Close project and environment selector')
->not->toContain('DropdownMenu')
->not->toContain('fixed inset-0 bg-black/80')
->not->toContain('fixed inset-y-0 right-0')
->not->toContain('<a')
->not->toContain("import { Button } from '@/components/ui/button';")
->not->toContain('<Button')
->not->toContain("import { Separator } from '@/components/ui/separator';")
->not->toContain('<Separator')
->not->toContain('min-h-[calc(100vh-4rem)]')
->not->toContain('py-10')
->not->toContain('bg-background/95')
->not->toContain('backdrop-blur')
->not->toContain('supports-[backdrop-filter]')
->not->toContain('border-coolgray')
->not->toContain('className="w-[12rem]"')
->not->toContain('<h1>Coolify v5</h1>')
->not->toContain('<h2 id="clusters-heading">Clusters</h2>');
expect(file_exists(resource_path('js/v5/components/top-navigation-loading-indicator.tsx')))->toBeFalse();
});
it('tracks v5 server actions with reusable per-id loading state', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
$pendingIdsHook = file_get_contents(resource_path('js/v5/lib/use-pending-ids.ts'));
expect($clustersPage)
->toContain("import { usePendingIds } from '@/lib/use-pending-ids';")
->toContain('const checkingServers = usePendingIds<string>()')
->toContain('const isCheckingServer = checkingServers.has(server.id)')
->toContain('checkingServers.start(server.id)')
->toContain('checkingServers.finish(server.id)')
->not->toContain('const [checkingServerId, setCheckingServerId] = useState<string | null>(null)')
->not->toContain('setCheckingServerId(server.id)')
->not->toContain('setCheckingServerId(null)');
expect($pendingIdsHook)
->toContain('export function usePendingIds')
->toContain('const [pendingIds, setPendingIds] = useState<Set<T>>(() => new Set())')
->toContain('start')
->toContain('finish')
->toContain('has')
->toContain('hasAny');
});
it('defines the v5 cluster management page and create cluster form', function () {
$clustersPagePath = resource_path('js/v5/Pages/Clusters.tsx');
$clustersPage = file_get_contents($clustersPagePath);
$buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx'));
$dialogComponent = file_get_contents(resource_path('js/v5/components/ui/dialog.tsx'));
$types = file_get_contents(resource_path('js/v5/types.ts'));
expect(file_exists($clustersPagePath))->toBeTrue();
expect($clustersPage)
->toContain("import { Button } from '@/components/ui/button';")
->toContain("} from '@/components/ui/dialog';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->toContain("fetch('/v5/clusters'")
->toContain('aria-label="Create cluster"')
->toContain('setIsCreateDialogOpen(true)')
->toContain('Add cluster')
->toContain('variant="coolify"')
->toContain('border-warning bg-warning/10 text-foreground')
->not->toContain('border-primary bg-primary/10 text-foreground')
->toContain('<Dialog')
->toContain('<DialogTitle>Create cluster</DialogTitle>')
->toContain('<DialogDescription>')
->toContain('<DialogFooter>')
->not->toContain('<DialogClose')
->toContain('Cancel')
->toContain('<DialogContent className="max-w-md" showCloseButton={false}>')
->toContain('<DialogTitle>Confirm deletion</DialogTitle>')
->toContain('variant="delete"')
->toContain('aria-label="Add server to cluster"')
->toContain('setIsAddServerDialogOpen(true)')
->toContain('Add server')
->toContain('className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"')
->toContain('<DialogTitle>Add server</DialogTitle>')
->toContain('DialogContent className="max-w-3xl"')
->toContain('DialogContent className="max-w-2xl"')
->not->toContain('DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto"')
->not->toContain('DialogContent className="max-h-[90dvh] max-w-2xl overflow-y-auto"')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`')
->toContain('Bootstrap')
->toContain('lastCliSummary')
->toContain('Unable to bootstrap this server. Check the CLI state output.')
->toContain('border-destructive/30')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers`')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`')
->toContain('fetch(`/v5/clusters/${cluster.id}/servers/${server.id}`')
->toContain("method: 'PATCH'")
->toContain("method: 'DELETE'")
->toContain('Check SSH')
->toContain('Bootstrap: {server.status}')
->toContain('Latest SSH check')
->toContain('Delete')
->toContain('<DialogTitle>Edit server</DialogTitle>')
->toContain('Edit server')
->toContain('Save server')
->toContain('editServerBuilderCapacity')
->toContain('editServerBuilderCpuQuota')
->toContain('Networking and bootstrap settings stay locked after creation.')
->toContain('Bootstrap SSH user')
->toContain('Bootstrap SSH port')
->not->toContain('{server.sshUser}@{server.host}:{server.sshPort}')
->toContain('showAdvancedServerConfiguration')
->toContain('wireguardListenPortOverride')
->toContain('wireguardEndpointOverride')
->toContain('privateKeys')
->toContain('selectedPrivateKeyId')
->toContain('Private key')
->toContain('Select a private key')
->toContain('appearance-none')
->toContain('backgroundImage: `url("data:image/svg+xml')
->not->toContain('No private key')
->toContain('Create cluster')
->toContain('Cluster details')
->toContain('Servers in this cluster')
->toContain('selectedCluster')
->toContain('builderCapacity')
->toContain('privateKeyName')
->toContain('lastBootstrappedAt')
->toContain('deleteCluster')
->toContain('fetch(`/v5/clusters/${cluster.id}`')
->toContain("method: 'DELETE'")
->toContain('Delete cluster')
->toContain('selectedCluster.serversCount === 0')
->toContain('Only empty clusters can be deleted.')
->toContain('min-h-dvh overflow-y-auto bg-background text-foreground lg:h-dvh lg:overflow-hidden')
->toContain('flex min-h-dvh overflow-visible px-4 pt-16 lg:h-full lg:min-h-0 lg:overflow-hidden lg:px-6')
->toContain('flex max-h-80 flex-col rounded-lg border border-border bg-card lg:max-h-none lg:min-h-0')
->toContain('overflow-visible rounded-lg border border-border bg-card lg:min-h-0 lg:overflow-y-auto')
->toContain('flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end')
->toContain('mt-4 grid grid-cols-1 gap-3 text-xs sm:grid-cols-2')
->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]')
->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]')
->not->toContain('New cluster')
->not->toContain('<aside className="rounded-lg border border-border bg-card p-5">')
->not->toContain('This is where the magic happens.');
expect(substr_count($clustersPage, 'variant="coolify"'))->toBe(5)
->and($buttonComponent)
->toContain('coolify:')
->toContain('bg-coollabs-50')
->toContain('hover:bg-coollabs')
->toContain('delete:');
expect($dialogComponent)
->toContain('@base-ui/react/dialog')
->toContain('z-50')
->toContain('max-h-[calc(100dvh-2rem)]')
->toContain('overflow-y-auto')
->toContain('top-1/2')
->toContain('-translate-y-1/2')
->not->toContain('top-4 bottom-4')
->not->toContain('top-1/2 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2')
->toContain('showCloseButton = true')
->toContain('aria-label="Close dialog"')
->toContain('<XIcon />')
->toContain('DialogTitle')
->toContain('DialogDescription');
$v5Css = file_get_contents(resource_path('css/v5/app.css'));
expect($v5Css)
->toContain('--color-warning: var(--warning);')
->toContain('--warning: #fcd452;')
->not->toContain('--ring: oklch(0.705 0.015 286.067);')
->not->toContain('--ring: oklch(0.552 0.016 285.938);');
expect(substr_count($v5Css, '--ring: var(--warning);'))->toBe(2);
expect($types)
->not->toContain('sshUser: string;')
->not->toContain('sshPort: number;')
->toContain('builderEnabled: boolean;')
->toContain('builderCapacity: number;')
->toContain('builderCpuQuota: string;')
->toContain('privateKeyName: string | null;')
->toContain('lastBootstrappedAt: string | null;');
});
it('defines a ghost variant for compact v5 select triggers', function () {
$select = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
expect($select)
->toContain('@base-ui/react/select')
->not->toContain('radix-ui')
->toContain("variant = 'default'")
->toContain("variant === 'default'")
->toContain("variant === 'ghost'")
->toContain('border-transparent')
->toContain('h-auto')
->toContain('text-sm')
->toContain("position === 'popper' ? false : true");
});
it('provides reusable v5 form field primitives with reserved validation error space', function () {
$fieldPath = resource_path('js/v5/components/ui/field.tsx');
$inputPath = resource_path('js/v5/components/ui/input.tsx');
$textareaPath = resource_path('js/v5/components/ui/textarea.tsx');
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect(file_exists($fieldPath))->toBeTrue()
->and(file_exists($inputPath))->toBeTrue()
->and(file_exists($textareaPath))->toBeTrue();
$field = file_get_contents($fieldPath);
$input = file_get_contents($inputPath);
$textarea = file_get_contents($textareaPath);
expect($field)
->toContain('function Field(')
->toContain('function FieldLabel(')
->toContain('function FieldError(')
->toContain('min-h-4')
->toContain('aria-live="polite"')
->toContain('message ? undefined : true')
->toContain('export { Field, FieldError, FieldLabel }');
expect($input)
->toContain('function Input(')
->toContain('aria-invalid:border-destructive')
->toContain('export { Input };');
expect($textarea)
->toContain('function Textarea(')
->toContain('aria-invalid:border-destructive')
->toContain('export { Textarea };');
expect($clustersPage)
->toContain("import { Field, FieldError, FieldLabel } from '@/components/ui/field';")
->toContain("import { Input } from '@/components/ui/input';")
->toContain("import { Textarea } from '@/components/ui/textarea';")
->toContain('<FieldError message={errors.name?.[0]} />')
->toContain('<FieldError message={serverErrors.private_key_id?.[0]} />')
->not->toContain('{errors.name ? <span className="text-xs text-destructive">{errors.name[0]}</span> : null}')
->not->toContain('{serverErrors.name ? (');
});
it('uses the requested shadcn preset configuration for v5', function () {
$components = json_decode(file_get_contents(base_path('components.json')), true);
$css = file_get_contents(resource_path('css/v5/app.css'));
expect($components['style'])
->toBe('base-lyra')
->and($components['tsx'])->toBeTrue()
->and($components['iconLibrary'])->toBe('phosphor')
->and($components['tailwind']['css'])->toBe('resources/css/v5/app.css')
->and($components['tailwind']['baseColor'])->toBe('zinc')
->and($css)->toContain('@import "@fontsource-variable/geist";')
->and($css)->toContain('--foreground: oklch(0.141 0.005 285.823);')
->and($css)->toContain('--background: #101010;')
->and($css)->toContain('button:not(:disabled)');
});
it('selects a shared team when the session has no current team', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Grace Hopper',
'email' => 'grace@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Auto Selected Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'admin']);
$this
->actingAs($user)
->get('/v5')
->assertSuccessful()
->assertSessionHas('currentTeam')
->assertDontSee('Auto Selected Team')
->assertDontSee('admin');
});
it('serves v5 dev assets from the current request host', function (string $url, string $viteHost) {
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$hotFile = public_path('hot');
$originalHotFile = file_exists($hotFile) ? file_get_contents($hotFile) : null;
file_put_contents($hotFile, 'http://configured-vite-host.test:5173');
try {
$response = $this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get($url);
$response
->assertSuccessful()
->assertSee("import RefreshRuntime from 'http://{$viteHost}:5173/@react-refresh'", false)
->assertSee("src=\"http://{$viteHost}:5173/@vite/client\"", false)
->assertSee("src=\"http://{$viteHost}:5173/resources/js/v5/app.tsx\"", false)
->assertDontSee('configured-vite-host.test', false);
} finally {
if ($originalHotFile === null) {
@unlink($hotFile);
} else {
file_put_contents($hotFile, $originalHotFile);
}
}
})->with([
'localhost' => ['http://localhost:8000/v5', 'localhost'],
'tailscale ip' => ['http://100.64.0.10:8000/v5', '100.64.0.10'],
]);
it('configures the vite dev server for remote v5 access', function () {
$viteConfig = file_get_contents(base_path('vite.config.js'));
expect($viteConfig)
->toContain('host: "0.0.0.0"')
->toContain('allowedHosts: true')
->toContain('cors: true')
->toContain('const viteHmrHost = env.VITE_HMR_HOST || null;')
->toContain('hmr: viteHmrHost')
->not->toContain('hmr: viteHost');
});
it('shows when flux is unavailable', function () {
$this->withoutVite();
fakeFluxHealth(false, 'Flux socket was not found.');
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Katherine Johnson',
'email' => 'katherine@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Flux Test Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('Unavailable')
->assertSee('Flux socket was not found.');
});
it('does not include coolify version controls on the v5 dashboard page', function () {
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardPage)
->not->toContain('Check coolify version')
->not->toContain('/v5/coolify/version')
->not->toContain('Installed version:');
});
it('renders flux status as a compact summary', function () {
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
expect($navbar)
->toContain('Flux: {flux?.label ??')
->toContain('title={flux?.socket ?? flux?.message ?? undefined}')
->toContain('{clusters.length} clusters')
->not->toContain('<h2 id="flux-status-heading">Flux status</h2>')
->not->toContain('<p>{flux.message}</p>')
->not->toContain('Socket: {flux.socket}');
});
it('syncs dev Lima VMs into v5 clusters and servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Dev Lima Key');
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
'--team-id' => $team->id,
'--user-id' => $user->id,
'--cluster' => 'Development-Lima',
'--builder-capacity' => 2,
'--server' => [
'coold-dev|host.docker.internal|developer|61332',
'coold-dev-2|host.docker.internal|developer|61379',
],
]);
expect($exitCode)->toBe(0)
->and(Cluster::query()->where('name', 'Development-Lima')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 61332)->where('private_key_id', $privateKey->id)->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('ssh_port', 61379)->where('private_key_id', $privateKey->id)->exists())->toBeTrue();
});
it('updates legacy dev Lima hostnames to Docker reachable SSH endpoints', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Dev Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
'--team-id' => $team->id,
'--user-id' => $user->id,
'--cluster' => 'Development-Lima',
'--builder-capacity' => 2,
'--server' => [
'coold-dev|host.docker.internal|developer|61332',
],
]);
expect($exitCode)->toBe(0)
->and(V5Server::query()->where('name', 'coold-dev')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 61332)->exists())->toBeTrue()
->and(V5Server::query()->where('host', 'lima-coold-dev')->exists())->toBeFalse();
});
it('seeds dev Lima VMs into v5 clusters and servers idempotently', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5PrivateKey($team, 'Dev Lima Key');
config()->set('coold.dev_builder_capacity', 2);
(new V5DevLimaSeeder)->run();
(new V5DevLimaSeeder)->run();
$cluster = Cluster::query()->where('name', 'Development-Lima')->sole();
expect($cluster->team_id)->toBe($team->id)
->and($cluster->created_by_user_id)->toBe($user->id)
->and($cluster->description)->toBe('Local Lima development cluster managed by scripts/dev.sh.')
->and(V5Server::query()->count())->toBe(2)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_user', get_current_user())->where('ssh_port', 60001)->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('ssh_user', get_current_user())->where('ssh_port', 60002)->exists())->toBeTrue()
->and(V5Server::query()->where('status', 'installed')->count())->toBe(2)
->and(V5Server::query()->where('builder_enabled', true)->where('builder_capacity', 2)->count())->toBe(2)
->and(V5Server::query()->where('cluster_id', $cluster->id)->count())->toBe(2);
});
function fakeFluxHealth(bool $available = true, string $message = 'Flux is running.'): void
{
app()->instance(FluxHealth::class, Mockery::mock(FluxHealth::class, function (MockInterface $mock) use ($available, $message) {
$mock->shouldReceive('check')
->once()
->andReturn([
'available' => $available,
'label' => $available ? 'Running' : 'Unavailable',
'message' => $message,
'socket' => '/run/coolify/flux.sock',
]);
}));
}
function createSharedUserAndTeamTables(): void
{
Schema::create('users', function ($table) {
$table->id();
$table->string('name')->default('Anonymous');
$table->string('email');
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->rememberToken();
$table->timestamps();
});
Schema::create('teams', function ($table) {
$table->id();
$table->string('name');
$table->string('description')->nullable();
$table->boolean('personal_team')->default(false);
$table->boolean('show_boarding')->default(false);
$table->timestamps();
});
Schema::create('private_keys', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->string('name');
$table->string('description')->nullable();
$table->longText('private_key');
$table->string('fingerprint')->nullable();
$table->boolean('is_git_related')->default(false);
$table->foreignId('team_id');
$table->timestamps();
});
Schema::create('projects', function ($table) {
$table->id();
$table->string('uuid');
$table->string('name');
$table->text('description')->nullable();
$table->foreignId('team_id');
$table->timestamps();
});
Schema::create('environments', function ($table) {
$table->id();
$table->string('name');
$table->foreignId('project_id');
$table->timestamps();
$table->text('description')->nullable();
$table->string('uuid');
});
Schema::create('v5_clusters', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('created_by_user_id');
$table->string('name');
$table->text('description')->nullable();
$table->string('wireguard_interface')->default('wg0');
$table->string('wireguard_management_pool')->default('100.64.0.0/16');
$table->unsignedInteger('wireguard_listen_port')->default(51820);
$table->string('container_network_pool')->default('10.210.0.0/16');
$table->unsignedTinyInteger('container_network_prefix')->default(24);
$table->json('namespaces')->nullable();
$table->boolean('default_deny_containers')->default(true);
$table->string('coold_version')->default('nightly');
$table->string('corrosion_version')->default('v1.0.0');
$table->unsignedInteger('corrosion_gossip_port')->default(8787);
$table->unsignedInteger('corrosion_api_port')->default(8080);
$table->boolean('builder_enabled')->default(true);
$table->unsignedInteger('builder_capacity')->default(2);
$table->string('builder_cpu_quota')->default('200%');
$table->string('builder_memory_max')->default('2G');
$table->unsignedInteger('builder_timeout_secs')->default(1800);
$table->string('last_cli_action')->nullable();
$table->string('last_cli_status')->nullable();
$table->text('last_cli_summary')->nullable();
$table->timestamp('last_cli_ran_at')->nullable();
$table->timestamps();
});
Schema::create('v5_servers', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('cluster_id')->nullable();
$table->foreignId('created_by_user_id');
$table->foreignId('private_key_id')->nullable();
$table->string('name');
$table->string('host');
$table->string('ssh_user');
$table->unsignedInteger('ssh_port');
$table->string('status')->default('installed');
$table->json('capabilities')->nullable();
$table->boolean('builder_enabled')->default(false);
$table->unsignedInteger('builder_capacity')->default(0);
$table->string('builder_cpu_quota')->default('200%');
$table->string('node_address')->nullable();
$table->unsignedInteger('wireguard_listen_port_override')->nullable();
$table->string('wireguard_endpoint_override')->nullable();
$table->string('wireguard_management_ip')->nullable();
$table->string('wireguard_public_key')->nullable();
$table->json('container_subnets')->nullable();
$table->timestamp('last_bootstrapped_at')->nullable();
$table->string('last_status_check')->nullable();
$table->text('last_status_output')->nullable();
$table->timestamp('last_status_checked_at')->nullable();
$table->timestamps();
});
Schema::create('team_user', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('user_id');
$table->string('role')->default('member');
$table->timestamps();
$table->unique(['team_id', 'user_id']);
});
}
/**
* @return array{0: Project, 1: Environment}
*/
function createV5ProjectWithEnvironment(Team $team, string $projectName, string $environmentName): array
{
$project = Project::withoutEvents(fn () => Project::query()->forceCreate([
'uuid' => str($projectName)->slug().'-uuid',
'name' => $projectName,
'description' => null,
'team_id' => $team->id,
]));
$environment = Environment::withoutEvents(fn () => Environment::query()->forceCreate([
'uuid' => str($environmentName)->slug().'-uuid',
'name' => $environmentName,
'description' => null,
'project_id' => $project->id,
]));
return [$project, $environment];
}
function createV5PrivateKey(Team $team, string $name): PrivateKey
{
return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([
'uuid' => str($name)->slug().'-uuid',
'name' => $name,
'description' => null,
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
'fingerprint' => str($name)->slug()->toString(),
'is_git_related' => false,
'team_id' => $team->id,
]));
}
/**
* @return array{0: User, 1: Team}
*/
function createV5UserWithTeam(): array
{
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Margaret Hamilton',
'email' => 'margaret@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'V5 Tooling Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
return [$user, $team];
}