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.show'))->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.applications.nginx'))->toBeTrue() ->and(Route::has('v5.applications.refresh'))->toBeTrue() ->and(Route::has('v5.applications.position'))->toBeTrue() ->and(Route::has('v5.applications.ingress'))->toBeTrue() ->and(Route::has('v5.caddy-ingresses.position'))->toBeTrue() ->and(Route::has('v5.applications.destroy'))->toBeTrue() ->and(Route::has('v5.resource-connections.store'))->toBeTrue() ->and(Route::has('v5.resource-connections.update'))->toBeTrue() ->and(Route::has('v5.resource-connections.destroy'))->toBeTrue() ->and(Route::has('v5.realtime-test'))->toBeTrue() ->and(Route::has('v5.realtime-test.broadcast'))->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('keeps long v5 application metadata inside the canvas card', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('overflow-hidden') ->and($dashboardSource)->toContain('grid grid-cols-[auto_minmax(0,1fr)]') ->and($dashboardSource)->toContain('truncate text-right font-medium') ->and($dashboardSource)->toContain('truncate text-right font-mono'); }); it('does not render v5 application status messages on dashboard cards', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->not->toContain('{application.statusMessage && (') ->not->toContain('{application.statusMessage}

'); }); it('uses the shared dialog and button components for the application ingress modal', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('toContain('open') ->toContain('toContain('showCloseButton') ->toContain('') ->not->toContain('>Close'); }); it('shows a loading state while toggling application ingress', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('savingIngressApplicationId') ->toContain("isApplicationIngressSaving ? 'Saving...'") ->toContain("isSavingIngress ? 'Saving...' : 'Enable ingress'"); }); it('generates http-only caddy routes for application ingress', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.10', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-test', 'status' => 'running', 'mesh_namespace' => 'default', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyCaddyIngress') ->once() ->with( '100.64.0.10', Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')), Mockery::on(fn (array $apps): bool => count($apps) === 1 && str_contains($apps[0]['caddyfile'], 'http://app.example.com {') && ! str_contains($apps[0]['caddyfile'], 'https://') && str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000')) ) ->andReturn('Caddy ingress applied.'); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/applications/{$application->id}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], ]) ->assertSuccessful(); }); it('shows a dashboard refresh button next to the center button', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('onClick={() => centerOnCanvasNodes()}') ->toContain('onClick={() => void refreshApplications()}') ->toContain("isRefreshing ? 'Refreshing…' : 'Refresh state'"); }); it('subscribes the v5 dashboard canvas to automatic resource status updates', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('currentTeam = null') ->toContain("channel.listen('.v5.canvas.resource.updated'") ->toContain('setApplications((currentApplications) =>') ->toContain('setIngresses((currentIngresses) =>') ->toContain('Waiting for window.Echo before subscribing to canvas updates') ->toContain("channel.listen('.v5.canvas.resource.updated'") ->not->toContain("fetch('/v5/canvas-state'"); }); it('allows zooming the v5 dashboard canvas with buttons and pinch gestures', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('zoom: number;') ->toContain('MIN_CANVAS_ZOOM') ->toContain('MAX_CANVAS_ZOOM') ->toContain('PINCH_CANVAS_ZOOM_STEP') ->toContain('zoomCanvas(') ->toContain('zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP') ->toContain('onWheel={handleCanvasWheel}') ->toContain('event.ctrlKey') ->toContain('aria-label="Zoom out"') ->toContain('aria-label="Zoom in"') ->toContain('Math.round(viewport.zoom * 100)') ->toContain('scale(${viewport.zoom})'); }); it('renders draggable connector dots on v5 application canvas cards', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain("type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';") ->toContain('application-connector') ->toContain('data-connector-side={side}') ->toContain('startConnectionDrag(event, application.id, side)') ->toContain('') ->toContain('draftConnection'); }); it('keeps v5 canvas connections selectable unique and shortest-path only', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('type CanvasConnection = V5ResourceConnection;') ->toContain('resourceConnections: initialResourceConnections = []') ->toContain('useState(initialResourceConnections)') ->toContain('selectedConnectionId') ->toContain('clearCanvasSelection') ->toContain('event.target !== event.currentTarget') ->toContain('connectionExists') ->toContain('shortestConnectionPoints') ->toContain("!['Backspace', 'Delete'].includes(event.key)") ->toContain('deletePersistedConnection(connectionId)') ->toContain('onClick={(event) => selectConnection(event, connection.id)}') ->toContain("selectedConnectionId === connection.id ? 'stroke-destructive' : 'stroke-warning'") ->toContain('aria-label="Select connection"') ->toContain('stroke="transparent"') ->toContain('strokeWidth={12}') ->toContain('data-application-card="application-card"') ->toContain("closest('[data-application-card]')") ->toContain('deleteConnection(connection.id)') ->toContain('Delete connection') ->toContain('left: (points.from.x + points.to.x) / 2') ->toContain('top: (points.from.y + points.to.y) / 2') ->toContain('id="dashboard-connection-arrow"') ->toContain('markerEnd={selectedConnectionId === connection.id ? \'url(#dashboard-connection-arrow)\' : undefined}') ->toContain('markerWidth="16"') ->toContain('markerHeight="16"') ->toContain('strokeDasharray="6 6"') ->toContain('persistNewConnection(pointerState.from.applicationId, targetApplicationId)') ->toContain('persistConnectionPorts(updatedConnection)') ->toContain('/v5/resource-connections') ->toContain('ports_by_direction: portsByDirection') ->toContain('connectionDirectionKey(') ->toContain('activeConnectionPorts(connection)') ->toContain('addConnectionPort(connection.id)') ->toContain('Number.isInteger(portNumber)') ->toContain('setConnectionPortInput') ->toContain('Allowed ports') ->toContain('updateConnectionDirection(') ->toContain('applicationDirectionLabel(') ->toContain('application.id.slice(0, 8)') ->toContain('connection.applicationIds[0]') ->toContain('connection.applicationIds[1]') ->toContain('group/application') ->toContain('opacity-0') ->toContain('group-hover/application:opacity-100'); }); it('shows v5 application connector dots after selecting a canvas card', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('selectedApplicationId') ->toContain('setSelectedApplicationId(application.id)') ->toContain('selectedApplicationId === application.id') ->toContain('opacity-100'); }); it('uses a larger mobile touch target for v5 application connector dots', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('size-8') ->toContain('md:size-3') ->toContain('group/connector') ->toContain('group-hover/connector:scale-125') ->toContain(''); }); it('detects the connection drop target from pointer coordinates for mobile drags', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); expect($dashboardSource) ->toContain('connectionTargetFromPointer(event)') ->toContain('document.elementFromPoint(event.clientX, event.clientY)') ->toContain('pointer-captured mobile drags') ->toContain('targetApplicationId !== pointerState.from.applicationId'); }); 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(); 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(); 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', 'uuid', 'wireguard_management_ip', 'wireguard_public_key', 'container_subnets', 'last_bootstrapped_at', 'last_bootstrap_action', 'last_bootstrap_status', 'last_bootstrap_output', 'last_bootstrap_ran_at', 'last_status_check', 'last_status_output', 'last_status_checked_at', 'created_at', 'updated_at', ]))->toBeTrue(); }); it('creates v5 server canvas columns for movable caddy ingress nodes', 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(); expect(Schema::hasColumns('v5_servers', [ 'canvas_x', 'canvas_y', ]))->toBeTrue(); }); it('creates v5 server ingress columns', 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(); expect(Schema::hasColumns('v5_servers', [ 'ingress_type', 'ingress_status', ]))->toBeTrue(); }); it('creates v5 application tables for dashboard canvas nodes', function () { createSharedUserAndTeamTables(); Schema::dropIfExists('v5_application_domains'); Schema::dropIfExists('v5_applications'); 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(); $applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php'); $applicationMigration->up(); expect(Schema::hasTable('v5_applications'))->toBeTrue() ->and(Schema::hasColumns('v5_applications', [ 'id', 'team_id', 'project_id', 'environment_id', 'server_id', 'created_by_user_id', 'name', 'image', 'container_name', 'status', 'status_message', 'runtime_container_id', 'mesh_namespace', 'ingress_enabled', 'internal_port', 'canvas_x', 'canvas_y', 'created_at', 'updated_at', ]))->toBeTrue(); }); it('creates v5 application domain tables for zero or more inbound routes', function () { createSharedUserAndTeamTables(); Schema::dropIfExists('v5_application_domains'); Schema::dropIfExists('v5_applications'); 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(); $applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php'); $applicationMigration->up(); expect(Schema::hasTable('v5_application_domains'))->toBeTrue() ->and(Schema::hasColumns('v5_application_domains', [ 'id', 'application_id', 'domain', 'created_at', 'updated_at', ]))->toBeTrue() ->and(Schema::hasColumns('v5_applications', [ 'ingress_enabled', 'internal_port', ]))->toBeTrue(); }); it('creates generic v5 resource connection tables', function () { createSharedUserAndTeamTables(); Schema::dropIfExists('v5_resource_connection_rules'); Schema::dropIfExists('v5_resource_connections'); Schema::dropIfExists('v5_application_domains'); Schema::dropIfExists('v5_applications'); 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(); $applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php'); $applicationMigration->up(); $connectionMigration = include database_path('migrations/2026_06_19_142000_v5_create_resource_connections_table.php'); $connectionMigration->up(); expect(Schema::hasTable('v5_resource_connections'))->toBeTrue() ->and(Schema::hasColumns('v5_resource_connections', [ 'id', 'team_id', 'project_id', 'environment_id', 'resource_one_type', 'resource_one_id', 'resource_two_type', 'resource_two_id', 'resource_pair_key', 'created_by_user_id', 'created_at', 'updated_at', ]))->toBeTrue() ->and(Schema::hasTable('v5_resource_connection_rules'))->toBeTrue() ->and(Schema::hasColumns('v5_resource_connection_rules', [ 'id', 'connection_id', 'source_resource_type', 'source_resource_id', 'target_resource_type', 'target_resource_id', 'protocol', 'port', 'created_at', 'updated_at', ]))->toBeTrue(); }); it('keeps v5 server fields in the initial migration', 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(); expect(Schema::hasColumns('v5_servers', [ 'uuid', 'ingress_type', 'ingress_status', 'builder_cpu_quota', 'node_address', 'wireguard_management_ip', 'container_subnets', 'canvas_x', 'canvas_y', 'last_bootstrap_output', 'last_status_output', ]))->toBeTrue(); }); 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('CREATE TABLE IF NOT EXISTS "v5_container_statuses"') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_applications"') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_application_domains"') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connections"') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connection_rules"') ->and($schema)->toContain('"domain" TEXT NOT NULL') ->and($schema)->toContain('"ingress_enabled" INTEGER DEFAULT false NOT NULL') ->and($schema)->toContain('"internal_port" INTEGER') ->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('"ingress_type" TEXT') ->and($schema)->toContain('"ingress_status" TEXT') ->and($schema)->toContain('"builder_cpu_quota" TEXT DEFAULT \'200%\' NOT NULL') ->and($schema)->toContain('"uuid" TEXT') ->and($schema)->toContain('"wireguard_management_ip" TEXT') ->and($schema)->toContain('"container_subnets" JSON') ->and($schema)->toContain('"canvas_x" INTEGER') ->and($schema)->toContain('"canvas_y" INTEGER') ->and($schema)->toContain('"last_bootstrap_output" TEXT') ->and($schema)->toContain('"last_status_output" TEXT') ->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table') ->and($schema)->toContain('2026_06_19_140000_v5_create_applications_table') ->and($schema)->toContain('2026_06_19_142000_v5_create_resource_connections_table') ->and($schema)->toContain('2026_06_19_182231_create_container_statuses_table') ->and($schema)->not->toContain('2026_06_19_141231_add_canvas_position_to_v5_servers_table') ->and($schema)->not->toContain('2026_06_19_173933_add_ingress_status_to_v5_servers_table') ->and($schema)->not->toContain('2026_06_20_072818_v5_add_ingress_routing_to_applications_table') ->and($schema)->not->toContain('2026_06_19_150000_add_mesh_namespace_to_v5_applications_table') ->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table') ->and($schema)->not->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers') ->and($schema)->not->toContain('2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table') ->and($schema)->not->toContain('2026_06_17_172845_add_status_check_fields_to_v5_servers_table') ->and($schema)->not->toContain('2026_06_19_064539_add_bootstrap_log_fields_to_v5_servers_table') ->and($schema)->not->toContain('2026_06_19_090217_add_uuid_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(); $this->withoutExceptionHandling(); 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('', false) ->assertSee('coolify-logo-dev-transparent.png', false) ->assertDontSee('coolify-logo.svg', false) ->assertSee('js/echo.js', false) ->assertSee('js/pusher.js', false) ->assertSee('window.Echo', 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') ->assertSee('currentTeam', false) ->assertSee('"currentTeam":{"id":'.$team->id, false) ->assertDontSee('teams', false) ->assertDontSee('V5 Shared Team') ->assertDontSee('Shared team details'); }); it('serves v5 dashboard applications as canvas nodes', function () { app()->detectEnvironment(fn () => 'local'); $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); [$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($team, 'Staging Project', 'Staging'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', 'status_message' => 'Container started.', 'runtime_container_id' => 'abc123', 'mesh_namespace' => 'default', 'canvas_x' => 120, 'canvas_y' => -80, ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $otherProject->id, 'environment_id' => $otherEnvironment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'other-nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-other', 'status' => 'running', ]); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->get('/v5') ->assertSuccessful() ->assertSee('"applications":[', false) ->assertSee('"name":"nginx-test"', false) ->assertSee('"serverName":"edge-01"', false) ->assertSee('"meshNamespace":"default"', false) ->assertSee('"meshFqdn":"coolify-v5-nginx-1.default.coolify.internal"', false) ->assertSee('"canvasX":120', false) ->assertSee('"canvasY":-80', false) ->assertDontSee('other-nginx-test', false); }); it('persists generic v5 resource connections and direction-specific ports', function () { app()->detectEnvironment(fn () => 'local'); $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $source = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-source', 'status' => 'running', ]); $target = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-target', 'status' => 'running', ]); $response = $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, '_token' => 'test-csrf-token', ]) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') ->postJson('/v5/resource-connections', [ 'resource_one' => ['type' => 'application', 'id' => $source->id], 'resource_two' => ['type' => 'application', 'id' => $target->id], ]) ->assertCreated() ->assertJsonPath('connection.applicationIds.0', (string) $source->id) ->assertJsonPath('connection.applicationIds.1', (string) $target->id); $connectionId = $response->json('connection.id'); $this ->actingAs($user) ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') ->patchJson("/v5/resource-connections/{$connectionId}", [ 'ports_by_direction' => [ "{$source->id}->{$target->id}" => [80], "{$target->id}->{$source->id}" => [443], ], ]) ->assertSuccessful() ->assertJsonPath("connection.portsByDirection.{$source->id}->{$target->id}.0", '80') ->assertJsonPath("connection.portsByDirection.{$target->id}->{$source->id}.0", '443'); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->get('/v5') ->assertSuccessful() ->assertSee('"resourceConnections":[', false) ->assertSee("\"id\":\"{$connectionId}\"", false) ->assertSee("\"{$source->id}->{$target->id}\":[\"80\"]", false) ->assertSee("\"{$target->id}->{$source->id}\":[\"443\"]", false); }); it('serves enabled v5 caddy ingress servers as canvas nodes', function () { app()->detectEnvironment(fn () => 'local'); $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-ingress-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'ingress_status' => 'running', 'capabilities' => ['coold', 'ingress'], 'canvas_x' => -160, 'canvas_y' => 240, ]); V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-ingress-02', 'host' => '203.0.113.22', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'ingress_status' => 'exited', 'capabilities' => ['coold', 'ingress'], ]); V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-worker-01', 'host' => '203.0.113.21', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->get('/v5') ->assertSuccessful() ->assertSee('"caddyIngresses":[', false) ->assertSee('"name":"edge-ingress-01"', false) ->assertSee('"host":"203.0.113.20"', false) ->assertSee('"type":"caddy"', false) ->assertSee('"status":"running"', false) ->assertSee('"name":"edge-ingress-02"', false) ->assertSee('"status":"exited"', false) ->assertSee('"canvasX":-160', false) ->assertSee('"canvasY":240', false); }); it('creates an nginx v5 application on the first installed team server', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $privateKey = createV5PrivateKey($team, 'Production SSH Key'); V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'last_bootstrapped_at' => now(), ]); Process::fake([ '*' => Process::result(output: "nginx-container-id\n"), ]); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/nginx') ->assertCreated() ->assertJsonPath('application.name', 'nginx-test') ->assertJsonPath('application.image', 'docker.io/library/nginx:alpine') ->assertJsonPath('application.status', 'running') ->assertJsonPath('application.serverName', 'edge-01') ->assertJsonPath('application.meshNamespace', 'default') ->assertJsonPath('application.canvasX', 0) ->assertJsonPath('application.canvasY', 0); expect(V5Application::query() ->where('team_id', $team->id) ->where('project_id', $project->id) ->where('environment_id', $environment->id) ->where('name', 'nginx-test') ->where('status', 'running') ->where('runtime_container_id', 'nginx-container-id') ->exists())->toBeTrue(); }); it('creates an nginx v5 application on the selected team server', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $privateKey = createV5PrivateKey($team, 'Production SSH Key'); V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'last_bootstrapped_at' => now(), ]); $selectedServer = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey->id, 'name' => 'edge-02', 'host' => '203.0.113.11', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'last_bootstrapped_at' => now(), ]); Process::fake([ '*' => Process::result(output: "nginx-container-id\n"), ]); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/nginx', [ 'server_id' => $selectedServer->id, ]) ->assertCreated() ->assertJsonPath('application.serverName', 'edge-02'); expect(V5Application::query() ->where('server_id', $selectedServer->id) ->where('runtime_container_id', 'nginx-container-id') ->exists())->toBeTrue(); }); it('places a new nginx v5 application next to existing canvas nodes', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $privateKey = createV5PrivateKey($team, 'Production SSH Key'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-existing', 'status' => 'running', 'status_message' => 'Running.', 'mesh_namespace' => 'default', 'canvas_x' => 0, 'canvas_y' => 0, ]); Process::fake([ '*' => Process::result(output: "nginx-container-id\n"), ]); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/nginx') ->assertCreated() ->assertJsonPath('application.canvasX', 352) ->assertJsonPath('application.canvasY', 0); }); it('marks an nginx v5 application failed when the launch command fails', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $privateKey = createV5PrivateKey($team, 'Production SSH Key'); V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'last_bootstrapped_at' => now(), ]); Process::fake([ '*' => Process::result(errorOutput: 'podman failed', exitCode: 1), ]); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/nginx') ->assertUnprocessable() ->assertJsonPath('application.status', 'failed') ->assertJsonPath('application.statusMessage', 'podman failed'); }); it('does not create an nginx v5 application on another teams selected server', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$otherUser, $otherTeam] = createV5UserWithTeam('other@example.com'); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $privateKey = createV5PrivateKey($otherTeam, 'Other SSH Key'); $otherServer = V5Server::query()->create([ 'team_id' => $otherTeam->id, 'created_by_user_id' => $otherUser->id, 'private_key_id' => $privateKey->id, 'name' => 'other-edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'last_bootstrapped_at' => now(), ]); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/nginx', [ 'server_id' => $otherServer->id, ]) ->assertUnprocessable() ->assertJsonPath('message', 'Add a v5 server before deploying nginx.'); expect(V5Application::query()->count())->toBe(0); }); it('does not create an nginx v5 application without a team server', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/nginx') ->assertUnprocessable() ->assertJsonPath('message', 'Add a v5 server before deploying nginx.'); expect(V5Application::query()->count())->toBe(0); }); it('deletes a v5 application for the current team', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->deleteJson("/v5/applications/{$application->id}") ->assertNoContent(); expect(V5Application::query()->whereKey($application->id)->exists())->toBeFalse(); }); it('stops and deletes the nginx container before deleting a v5 application', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $privateKey = createV5PrivateKey($team, 'Production SSH Key'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', 'runtime_container_id' => 'nginx-container-id', ]); Process::fake([ '*' => Process::result(), ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->deleteJson("/v5/applications/{$application->id}") ->assertNoContent(); Process::assertRan(function ($process): bool { $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; return is_string($command) && str_contains($command, '203.0.113.10') && str_contains($command, 'podman rm -f') && str_contains($command, 'coolify-v5-nginx-1'); }); expect(V5Application::query()->whereKey($application->id)->exists())->toBeFalse(); }); it('does not delete another teams v5 application', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$otherUser, $otherTeam] = createV5UserWithTeam(); [$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $otherTeam->id, 'created_by_user_id' => $otherUser->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $application = V5Application::query()->create([ 'team_id' => $otherTeam->id, 'project_id' => $otherProject->id, 'environment_id' => $otherEnvironment->id, 'server_id' => $server->id, 'created_by_user_id' => $otherUser->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->deleteJson("/v5/applications/{$application->id}") ->assertNotFound(); expect(V5Application::query()->whereKey($application->id)->exists())->toBeTrue(); }); it('updates v5 application canvas position for the current team', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', 'canvas_x' => 0, 'canvas_y' => 0, ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/applications/{$application->id}/position", [ 'canvas_x' => 320, 'canvas_y' => -160, ]) ->assertSuccessful() ->assertJsonPath('application.canvasX', 320) ->assertJsonPath('application.canvasY', -160); expect($application->refresh()->canvas_x)->toBe(320) ->and($application->canvas_y)->toBe(-160); }); it('updates v5 caddy ingress canvas position for the current team', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-ingress-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], 'canvas_x' => -352, 'canvas_y' => 0, ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/caddy-ingresses/{$server->id}/position", [ 'canvas_x' => -160, 'canvas_y' => 240, ]) ->assertSuccessful() ->assertJsonPath('caddyIngress.canvasX', -160) ->assertJsonPath('caddyIngress.canvasY', 240); expect($server->refresh()->canvas_x)->toBe(-160) ->and($server->canvas_y)->toBe(240); }); it('applies flux application status updates to the database and broadcasts to the team canvas', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'wireguard_management_ip' => '100.64.0.5', ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', 'status_message' => 'Container started.', 'runtime_container_id' => 'nginx-container-id', 'canvas_x' => 0, 'canvas_y' => 0, ]); Event::fake([V5CanvasResourceUpdated::class]); $resource = ApplyFluxResourceStatusUpdate::run([ 'resource_type' => 'application', 'host_id' => '100.64.0.5', 'container_name' => 'coolify-v5-nginx-1', 'container_id' => 'new-nginx-container-id', 'status' => 'exited', 'status_message' => 'Status received from coold through flux.', ]); expect($resource)->toBeInstanceOf(V5Application::class) ->and($application->refresh()->status)->toBe('exited') ->and($application->status_message)->toBe('Status received from coold through flux.') ->and($application->runtime_container_id)->toBe('new-nginx-container-id'); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->applicationId === $application->id); }); it('maps generic flux container status updates to v5 applications', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'wireguard_management_ip' => '100.64.0.5', ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', 'status_message' => 'Container started.', 'runtime_container_id' => 'nginx-container-id', ]); Event::fake([V5CanvasResourceUpdated::class]); $resource = ApplyFluxResourceStatusUpdate::run([ 'resource_type' => 'container', 'host_id' => '100.64.0.5', 'container_name' => 'coolify-v5-nginx-1', 'container_id' => 'nginx-container-id', 'status' => 'exited', 'status_message' => 'Container state received from coold.', ]); expect($resource)->toBeInstanceOf(V5Application::class) ->and($application->refresh()->status)->toBe('exited') ->and($application->status_message)->toBe('Container state received from coold.'); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->applicationId === $application->id); }); it('applies flux ingress server status updates to the database and broadcasts cluster plus canvas updates', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $cluster = Cluster::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'Production Cluster', ]); $server = V5Server::query()->create([ 'team_id' => $team->id, 'cluster_id' => $cluster->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], 'wireguard_management_ip' => '100.64.0.5', ]); Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]); $resource = ApplyFluxResourceStatusUpdate::run([ 'resource_type' => 'server', 'host_id' => '100.64.0.5', 'status' => 'unreachable', 'message' => 'coold heartbeat timed out.', ]); expect($resource)->toBeInstanceOf(V5Server::class) ->and($server->refresh()->status)->toBe('unreachable') ->and($server->last_status_check)->toBe('flux') ->and($server->last_status_output)->toBe('coold heartbeat timed out.') ->and($server->last_status_checked_at)->not->toBeNull(); Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id && $event->clusterId === $cluster->id); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->caddyIngressServerId === $server->id); }); it('applies flux caddy ingress container status updates without changing server install status', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'ingress_status' => 'running', 'capabilities' => ['coold', 'ingress'], 'wireguard_management_ip' => '100.64.0.5', ]); Event::fake([V5CanvasResourceUpdated::class]); $resource = ApplyFluxResourceStatusUpdate::run([ 'resource_type' => 'caddy_ingress', 'host_id' => '100.64.0.5', 'container_name' => 'coolify-v5-caddy', 'status' => 'exited', 'message' => 'Caddy container exited.', ]); expect($resource)->toBeInstanceOf(V5Server::class) ->and($server->refresh()->status)->toBe('installed') ->and($server->ingress_type)->toBe('caddy') ->and($server->ingress_status)->toBe('exited') ->and($server->last_status_check)->toBe('flux') ->and($server->last_status_output)->toBe('Caddy container exited.') ->and($server->last_status_checked_at)->not->toBeNull(); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->caddyIngressServerId === $server->id); }); it('accepts flux status updates for non coolify managed containers', function () { Config::set('flux.laravel_api_token', 'test-flux-token'); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'wireguard_management_ip' => '100.64.0.5', ]); $this ->postJson('/api/v1/internal/flux/resource-status', [ 'resource_type' => 'container', 'host_id' => '100.64.0.5', 'container_id' => 'external-container-id', 'container_name' => 'external-container', 'status' => 'running', ], [ 'Authorization' => 'Bearer test-flux-token', ]) ->assertSuccessful() ->assertJsonPath('message', 'Resource status updated.'); expect(ContainerStatus::query() ->where('container_id', 'external-container-id') ->where('container_name', 'external-container') ->where('status', 'running') ->exists())->toBeTrue(); }); it('rejects flux resource status http updates without the shared token', function () { Config::set('flux.laravel_api_token', 'test-flux-token'); $this ->postJson('/api/v1/internal/flux/resource-status', [ 'resource_type' => 'application', 'status' => 'running', ]) ->assertUnauthorized(); }); it('accepts flux resource status http updates and stores them in the database', function () { createSharedUserAndTeamTables(); Config::set('flux.laravel_api_token', 'test-flux-token'); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'wireguard_management_ip' => '100.64.0.5', ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'starting', 'status_message' => 'Container starting.', 'runtime_container_id' => 'old-container-id', ]); Event::fake([V5CanvasResourceUpdated::class]); $this ->withToken('test-flux-token') ->postJson('/api/v1/internal/flux/resource-status', [ 'resource_type' => 'application', 'host_id' => '100.64.0.5', 'container_name' => 'coolify-v5-nginx-1', 'container_id' => 'new-container-id', 'status' => 'running', 'status_message' => 'Status received from coold through flux.', ]) ->assertSuccessful() ->assertJsonPath('message', 'Resource status updated.'); expect($application->refresh()->status)->toBe('running') ->and($application->status_message)->toBe('Status received from coold through flux.') ->and($application->runtime_container_id)->toBe('new-container-id'); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->applicationId === $application->id); }); it('configures flux resource status updates for local http instead of redis', function () { $configSource = file_get_contents(config_path('flux.php')); expect($configSource) ->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN') ->not->toContain('APP_KEY') ->not->toContain('COOLIFY_FLUX_RESOURCE_STATUS_CHANNEL') ->not->toContain('resource_status_channel'); }); it('broadcasts v5 canvas resource updates when application state changes', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', 'status_message' => 'Container started.', 'runtime_container_id' => 'nginx-container-id', 'canvas_x' => 0, 'canvas_y' => 0, ]); Event::fake([V5CanvasResourceUpdated::class]); $application->update([ 'status' => 'exited', 'status_message' => 'Container stopped.', ]); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->applicationId === $application->id); }); it('broadcasts v5 cluster and canvas updates when ingress server state changes', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $cluster = Cluster::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'Production Cluster', ]); $server = V5Server::query()->create([ 'team_id' => $team->id, 'cluster_id' => $cluster->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], ]); Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]); $server->update(['status' => 'unreachable']); Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id && $event->clusterId === $cluster->id); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->caddyIngressServerId === $server->id); }); it('refreshes v5 application state from flux container inventory', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'wireguard_management_ip' => '100.64.0.5', ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-1', 'status' => 'running', 'status_message' => 'Container started.', 'runtime_container_id' => 'nginx-container-id', 'canvas_x' => 0, 'canvas_y' => 0, ]); $this->mock(FluxClient::class, function (MockInterface $mock): void { $mock->shouldReceive('listContainers') ->once() ->with('100.64.0.5') ->andReturn([ [ 'id' => 'nginx-container-id', 'name' => 'coolify-v5-nginx-1', 'image' => 'docker.io/library/nginx:alpine', 'state' => 'exited', 'networks' => ['coolify-default-mesh'], ], ]); }); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/refresh') ->assertSuccessful() ->assertJsonPath('applications.0.id', (string) $application->id) ->assertJsonPath('applications.0.status', 'exited') ->assertJsonPath('applications.0.statusMessage', 'Container state refreshed from coold.'); expect($application->refresh()->status)->toBe('exited') ->and($application->status_message)->toBe('Container state refreshed from coold.'); }); it('refreshes v5 caddy ingress state from flux container inventory', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); $server = V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'edge-ingress-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'ingress_status' => 'running', 'capabilities' => ['coold', 'ingress'], 'wireguard_management_ip' => '100.64.0.6', ]); Event::fake([V5CanvasResourceUpdated::class]); $this->mock(FluxClient::class, function (MockInterface $mock): void { $mock->shouldReceive('listContainers') ->once() ->with('100.64.0.6') ->andReturn([ [ 'id' => 'caddy-container-id', 'name' => 'coolify-v5-caddy', 'image' => 'docker.io/library/caddy:2-alpine', 'state' => 'exited', ], ]); }); $this ->actingAs($user) ->withSession([ 'currentTeam' => $team, 'v5.selectedProjectUuid' => $project->uuid, 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/refresh') ->assertSuccessful() ->assertJsonPath('caddyIngresses.0.id', (string) $server->id) ->assertJsonPath('caddyIngresses.0.type', 'caddy') ->assertJsonPath('caddyIngresses.0.status', 'exited'); expect($server->refresh()->status)->toBe('installed') ->and($server->ingress_type)->toBe('caddy') ->and($server->ingress_status)->toBe('exited') ->and($server->last_status_check)->toBe('flux') ->and($server->last_status_output)->toBe('Caddy ingress state refreshed from coold.'); Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id && $event->caddyIngressServerId === $server->id); }); it('broadcasts v5 cluster updates when bootstrap state changes', 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' => 'added', 'builder_enabled' => false, 'builder_capacity' => 0, ]); Queue::fake(); Event::fake([V5ClusterUpdated::class]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") ->assertAccepted(); Event::assertDispatched(V5ClusterUpdated::class, fn ($event): bool => $event->clusterId === $cluster->id && $event->teamId === $team->id); Process::fake([ '*' => Process::result(output: "Bootstrap completed\n"), ]); Event::fake([V5ClusterUpdated::class]); (new V5BootstrapServerJob($cluster->id, $server->id))->handle(); expect(Event::dispatched(V5ClusterUpdated::class)->count())->toBeGreaterThanOrEqual(2); }); it('returns fresh v5 cluster bootstrap state for realtime fallback refreshes', 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' => 'added', 'builder_enabled' => false, 'builder_capacity' => 0, 'last_bootstrap_action' => 'bootstrap', 'last_bootstrap_status' => 'running', 'last_bootstrap_output' => 'Starting Coolify CLI bootstrap for prod-01...', 'last_bootstrap_ran_at' => now(), ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->getJson("/v5/clusters/{$cluster->id}") ->assertSuccessful() ->assertJsonPath('cluster.id', (string) $cluster->id) ->assertJsonPath('cluster.servers.0.id', (string) $server->id) ->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'running') ->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Starting Coolify CLI bootstrap for prod-01...'); }); it('broadcasts v5 cluster updates immediately with an explicit echo event name', function () { $clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx')); $event = new V5ClusterUpdated(1, 1); expect($event)->toBeInstanceOf(ShouldBroadcastNow::class) ->and($event->broadcastAs())->toBe('v5.cluster.updated') ->and($clustersPage)->toContain("listen('.v5.cluster.updated'") ->and($clustersPage)->toContain('Waiting for window.Echo before subscribing to cluster updates'); }); it('serves a v5 realtime test page for manual websocket diagnostics', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $page = file_get_contents(resource_path('js/v5/Pages/RealtimeTest.tsx')); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->get('/v5/realtime-test') ->assertSuccessful() ->assertSee('RealtimeTest', false) ->assertSee((string) $team->id, false); expect($page) ->toContain("listen('.v5.realtime.test'") ->toContain('Subscribing to private-') ->toContain('Broadcast event'); }); it('broadcasts manual v5 realtime test events immediately', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); Event::fake([V5RealtimeTestEvent::class]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/realtime-test', [ 'message' => 'hello socket', ]) ->assertAccepted() ->assertJsonPath('message', 'Realtime test event broadcasted.'); Event::assertDispatched(V5RealtimeTestEvent::class, fn (V5RealtimeTestEvent $event): bool => $event->teamId === $team->id && $event->message === 'hello socket' && $event->broadcastAs() === 'v5.realtime.test' && $event instanceof ShouldBroadcastNow); }); 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('requires positive v5 cluster builder capacity when builders are enabled', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/clusters', [ 'name' => 'Zero Builder Mesh', 'builder_enabled' => true, 'builder_capacity' => 0, ]) ->assertUnprocessable() ->assertJsonValidationErrors(['builder_capacity']); }); 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', 'coold_version' => 'v9.9.9', 'corrosion_version' => 'v8.8.8', ]) ->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') ->assertJsonMissingPath('cluster.servers.0.cooldVersion') ->assertJsonMissingPath('cluster.servers.0.corrosionVersion') ->assertJsonPath('cluster.servers.0.privateKeyName', 'Production SSH Key') ->assertJsonPath('cluster.servers.0.status', 'added') ->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.ingressEnabled', false) ->assertJsonPath('cluster.servers.0.ingressType', null) ->assertJsonPath('cluster.servers.0.capabilities', ['coold']) ->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']); }); it('adds a v5 server with caddy ingress enabled', 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, ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson("/v5/clusters/{$cluster->id}/servers", [ 'name' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'private_key_id' => $privateKey->id, 'builder_enabled' => false, 'builder_capacity' => 0, 'ingress_enabled' => true, 'ingress_type' => 'caddy', ]) ->assertCreated() ->assertJsonPath('cluster.servers.0.ingressEnabled', true) ->assertJsonPath('cluster.servers.0.ingressType', 'caddy') ->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']); $server = V5Server::query()->where('name', 'edge-01')->first(); expect($server->capabilities)->toBe(['coold', 'ingress']) ->and($server->ingress_type)->toBe('caddy') ->and($server->isIngress())->toBeTrue(); }); it('keeps added v5 server builder capacity when builder is disabled', 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, 'builder_enabled' => false, 'builder_capacity' => 3, ]) ->assertCreated() ->assertJsonPath('cluster.servers.0.builderEnabled', false) ->assertJsonPath('cluster.servers.0.builderCapacity', 3) ->assertJsonPath('cluster.servers.0.capabilities', ['coold']); $server = V5Server::query()->where('name', 'prod-01')->first(); expect($server->builder_enabled)->toBeFalse() ->and($server->builder_capacity)->toBe(3) ->and($server->capabilities)->toBe(['coold']); }); it('requires positive v5 server builder capacity when builder is enabled', 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, ]); $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, 'builder_enabled' => true, 'builder_capacity' => 0, ]) ->assertUnprocessable() ->assertJsonValidationErrors(['builder_capacity']); }); 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 without storing 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' => 'added', '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('status', 'reachable') ->assertJsonPath('output', "SSH connection OK\nprod-01\nLinux aarch64\n/usr/bin/docker") ->assertJsonPath('checkedAt', fn (?string $value) => $value !== null); 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('added') ->and($server->last_status_check)->toBeNull() ->and($server->last_status_output)->toBeNull() ->and($server->last_status_checked_at)->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' => 'added', 'capabilities' => ['builder'], 'builder_enabled' => true, 'builder_capacity' => 4, 'wireguard_listen_port_override' => 51831, 'wireguard_endpoint_override' => 'prod-01.example.com:51831', ]); Queue::fake(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") ->assertAccepted() ->assertJsonPath('message', 'Bootstrap queued.') ->assertJsonPath('cluster.servers.0.status', 'added') ->assertJsonPath('cluster.servers.0.lastBootstrapAction', 'bootstrap') ->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued') ->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Queued Coolify bootstrap for prod-01.'); Queue::assertPushed(V5BootstrapServerJob::class); Process::fake([ '*' => Process::result(output: "Bootstrap completed\n"), ]); Event::fake([V5ClusterUpdated::class]); (new V5BootstrapServerJob($cluster->id, $server->id))->handle(); $server->refresh(); Process::assertRan(function ($process): bool { $command = $process->command; $node = cliFlagValue($command, '--nodes'); return $command[0] === '/tmp/coolify' && in_array('init', $command, true) && in_array('bootstrap', $command, true) && in_array('--ssh-config', $command, true) && is_string($node) && str_starts_with($node, 'v5-server-') && cliFlagValue($command, '--namespaces') === 'default,preview' && cliFlagValue($command, '--container-pool') === '10.211.0.0/16' && cliFlagValue($command, '--wg-mgmt-pool') === '100.65.0.0/16' && cliFlagValue($command, '--wg-interface') === 'wg-prod' && cliFlagValue($command, '--coold-version') === 'v0.2.0' && cliFlagValue($command, '--corrosion-version') === 'v1.1.0' && cliFlagValue($command, '--wg-listen-port-overrides') === $node.'=51831' && cliFlagValue($command, '--wg-endpoint-overrides') === $node.'=prod-01.example.com:51831' && ! in_array('--enable-builder', $command, true) && in_array('--yes', $command, true) && ! in_array('--new-nodes', $command, true); }); $cluster->refresh(); expect($server->status)->toBe('installed') ->and($server->last_bootstrap_status)->toBe('succeeded') ->and($server->last_bootstrap_output)->toBe('Bootstrap completed') ->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' => 'added', 'builder_enabled' => false, 'builder_capacity' => 0, ]); Queue::fake(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") ->assertAccepted(); Process::fake([ '*' => Process::result(output: "Bootstrap completed\n"), ]); Event::fake([V5ClusterUpdated::class]); (new V5BootstrapServerJob($cluster->id, $server->id))->handle(); Process::assertRan(fn ($process): bool => $process->command[0] === '/usr/local/bin/coolify'); }); it('keeps a v5 server added 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' => 'added', 'builder_enabled' => false, 'builder_capacity' => 0, ]); Queue::fake(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") ->assertAccepted() ->assertJsonPath('cluster.servers.0.status', 'added') ->assertJsonPath('cluster.servers.0.lastBootstrappedAt', null) ->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued'); Process::fake([ '*' => Process::result(errorOutput: "bootstrap failed\n", exitCode: 1), ]); Event::fake([V5ClusterUpdated::class]); (new V5BootstrapServerJob($cluster->id, $server->id))->handle(); $server->refresh(); expect($server->status)->toBe('added') ->and($server->last_bootstrap_status)->toBe('failed') ->and($server->last_bootstrap_output)->toBe('bootstrap failed') ->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, 'coold_version' => 'v0.3.0', 'corrosion_version' => 'v1.2.0', ]); $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' => 'added', 'builder_enabled' => false, 'builder_capacity' => 0, ]); Queue::fake(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson("/v5/clusters/{$cluster->id}/servers/{$newServer->id}/bootstrap") ->assertAccepted() ->assertJsonPath('cluster.servers.1.lastBootstrapAction', 'extend') ->assertJsonPath('cluster.servers.1.lastBootstrapStatus', 'queued'); Process::fake([ '*' => Process::result(output: "Extend completed\n"), ]); Event::fake([V5ClusterUpdated::class]); (new V5BootstrapServerJob($cluster->id, $newServer->id))->handle(); Process::assertRan(function ($process) use ($oldServer, $newServer): bool { $command = $process->command; $oldNode = "v5-server-{$oldServer->uuid}"; $newNode = "v5-server-{$newServer->uuid}"; 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) && cliFlagValue($command, '--coold-version') === 'v0.3.0' && cliFlagValue($command, '--corrosion-version') === 'v1.2.0'; }); $oldServer->refresh(); $newServer->refresh(); expect($oldServer->status)->toBe('installed') ->and($newServer->status)->toBe('installed') ->and($newServer->last_bootstrap_status)->toBe('succeeded') ->and($newServer->last_bootstrap_output)->toBe('Extend completed') ->and($newServer->last_bootstrapped_at)->not->toBeNull(); }); it('adopts a re-added v5 server that is already bootstrapped for the same cluster', 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' => 'added', 'builder_enabled' => false, 'builder_capacity' => 0, ]); Process::fake([ '*' => Process::result(output: json_encode([ 'cluster_id' => $cluster->id, 'server_uuid' => 'server-previous-uuid', 'wireguard_management_ip' => '100.64.0.10', 'wireguard_public_key' => 'public-key', 'container_subnets' => ['default' => '10.210.0.0/24'], ], JSON_THROW_ON_ERROR)."\n"), ]); Event::fake([V5ClusterUpdated::class]); (new V5BootstrapServerJob($cluster->id, $server->id))->handle(); Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify'); $server->refresh(); expect($server->status)->toBe('installed') ->and($server->uuid)->toBe('server-previous-uuid') ->and($server->wireguard_management_ip)->toBe('100.64.0.10') ->and($server->wireguard_public_key)->toBe('public-key') ->and($server->container_subnets)->toBe(['default' => '10.210.0.0/24']) ->and($server->last_bootstrap_status)->toBe('succeeded') ->and($server->last_bootstrap_output)->toBe('Adopted existing Coolify bootstrap state for this cluster.') ->and($server->last_bootstrapped_at)->not->toBeNull(); }); it('blocks bootstrapping a v5 server that belongs to another cluster', 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' => 'added', 'builder_enabled' => false, 'builder_capacity' => 0, ]); Process::fake([ '*' => Process::result(output: json_encode([ 'cluster_id' => $cluster->id + 100, 'server_uuid' => 'server-other-uuid', ], JSON_THROW_ON_ERROR)."\n"), ]); Event::fake([V5ClusterUpdated::class]); (new V5BootstrapServerJob($cluster->id, $server->id))->handle(); Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify'); $server->refresh(); expect($server->status)->toBe('added') ->and($server->last_bootstrap_status)->toBe('failed') ->and($server->last_bootstrap_output)->toContain('already bootstrapped for another cluster') ->and($server->last_bootstrapped_at)->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' => 'added', '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('deletes a bootstrapped 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' => '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}") ->assertSuccessful() ->assertJsonPath('cluster.serversCount', 0) ->assertJsonPath('cluster.servers', []); expect(V5Server::query()->whereKey($server->id)->exists())->toBeFalse(); }); 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.ingressEnabled', false) ->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']) ->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('updates editable v5 server caddy ingress capability independently from builder', 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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'added', 'capabilities' => ['coold', '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' => false, 'builder_capacity' => 2, 'builder_cpu_quota' => '200%', 'ingress_enabled' => true, 'ingress_type' => 'caddy', ]) ->assertSuccessful() ->assertJsonPath('cluster.servers.0.builderEnabled', false) ->assertJsonPath('cluster.servers.0.ingressEnabled', true) ->assertJsonPath('cluster.servers.0.ingressType', 'caddy') ->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']); $server->refresh(); expect($server->capabilities)->toBe(['coold', 'ingress']) ->and($server->ingress_type)->toBe('caddy') ->and($server->builder_enabled)->toBeFalse() ->and($server->isIngress())->toBeTrue(); }); it('rejects application ingress when server ingress is disabled', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'app-01', 'host' => '203.0.113.21', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.11', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'private-app', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-private-app', 'status' => 'running', 'mesh_namespace' => 'default', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient->shouldNotReceive('applyCaddyIngress'); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/applications/{$application->id}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], ]) ->assertUnprocessable() ->assertJsonPath('message', 'Enable ingress on the server before enabling app ingress.'); expect($application->refresh()->ingress_enabled)->toBeFalse() ->and($application->domains()->count())->toBe(0); }); it('enables application ingress without publishing domains by default', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.10', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-test', 'status' => 'running', 'mesh_namespace' => 'default', ]); V5ApplicationDomain::query()->create([ 'application_id' => $application->id, 'domain' => 'kept.example.com', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyCaddyIngress') ->once() ->with( '100.64.0.10', Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')), [] ) ->andReturn('Caddy ingress applied.'); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/applications/{$application->id}/ingress", [ 'ingress_enabled' => false, 'internal_port' => 8080, ]) ->assertSuccessful() ->assertJsonPath('application.ingressEnabled', false) ->assertJsonPath('application.internalPort', 8080) ->assertJsonPath('application.domains.0', 'kept.example.com'); expect($application->refresh()->ingress_enabled)->toBeFalse(); }); it('validates application ingress domains', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.10', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-test', 'status' => 'running', 'mesh_namespace' => 'default', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient->shouldNotReceive('applyCaddyIngress'); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/applications/{$application->id}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['https://bad.example.com'], ]) ->assertUnprocessable() ->assertInvalid(['domains.0']); expect($application->refresh()->ingress_enabled)->toBeFalse(); }); it('enables application ingress with explicit domains and port', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.10', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-test', 'status' => 'running', 'mesh_namespace' => 'default', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyCaddyIngress') ->once() ->with( '100.64.0.10', Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')), Mockery::on(fn (array $apps): bool => count($apps) === 1 && str_contains($apps[0]['caddyfile'], 'http://app.example.com {') && str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000')) ) ->andReturn('Caddy ingress applied.'); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/applications/{$application->id}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], ]) ->assertSuccessful() ->assertJsonPath('application.ingressEnabled', true) ->assertJsonPath('application.internalPort', 3000) ->assertJsonPath('application.domains.0', 'app.example.com'); expect($application->refresh()->ingress_enabled)->toBeTrue() ->and($application->internal_port)->toBe(3000) ->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']); }); it('returns flux error details when application ingress sync fails', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'ingress'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.10', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-test', 'status' => 'running', 'mesh_namespace' => 'default', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyCaddyIngress') ->once() ->andThrow(new RuntimeException('start Caddy ingress: podman exited with status 125')); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/applications/{$application->id}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], ]) ->assertStatus(502) ->assertJsonPath('message', 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.') ->assertJsonPath('detail', 'start Caddy ingress: podman exited with status 125'); }); it('syncs caddy ingress routes through flux when enabling ingress on an installed server', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.10', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-test', 'status' => 'running', 'mesh_namespace' => 'default', 'ingress_enabled' => true, 'internal_port' => 8080, ]); V5ApplicationDomain::query()->create([ 'application_id' => $application->id, 'domain' => 'nginx.example.com', ]); V5ApplicationDomain::query()->create([ 'application_id' => $application->id, 'domain' => 'www.nginx.example.com', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyCaddyIngress') ->once() ->with( '100.64.0.10', Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')), Mockery::on(fn (array $apps): bool => count($apps) === 1 && str_contains($apps[0]['caddyfile'], 'http://nginx.example.com {') && str_contains($apps[0]['caddyfile'], 'http://www.nginx.example.com {') && str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080')) ) ->andReturn('Caddy ingress applied.'); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'ingress_enabled' => true, 'ingress_type' => 'caddy', ]) ->assertSuccessful() ->assertJsonPath('cluster.servers.0.ingressEnabled', true) ->assertJsonPath('cluster.servers.0.ingressType', 'caddy'); expect($server->refresh()->ingress_type)->toBe('caddy') ->and($server->ingress_status)->toBe('running'); }); it('returns flux error details when server ingress activation fails', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); $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' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold'], 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'wireguard_management_ip' => '100.64.0.10', 'last_bootstrapped_at' => now(), ]); $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $user->id, 'name' => 'nginx-test', 'image' => 'docker.io/library/nginx:alpine', 'container_name' => 'coolify-v5-nginx-test', 'status' => 'running', 'mesh_namespace' => 'default', 'ingress_enabled' => true, 'internal_port' => 8080, ]); V5ApplicationDomain::query()->create([ 'application_id' => $application->id, 'domain' => 'nginx.example.com', ]); $fluxClient = Mockery::mock(FluxClient::class); $fluxClient ->shouldReceive('applyCaddyIngress') ->once() ->andThrow(new RuntimeException('validate Caddyfile: unrecognized directive')); app()->instance(FluxClient::class, $fluxClient); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', 'ingress_enabled' => true, 'ingress_type' => 'caddy', ]) ->assertStatus(502) ->assertJsonPath('message', 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.') ->assertJsonPath('detail', 'validate Caddyfile: unrecognized directive'); }); it('keeps editable v5 server builder capacity when disabling builder', 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'], 'builder_enabled' => true, 'builder_capacity' => 5, 'builder_cpu_quota' => '350%', ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ 'builder_enabled' => false, 'builder_capacity' => 5, 'builder_cpu_quota' => '350%', ]) ->assertSuccessful() ->assertJsonPath('cluster.servers.0.builderEnabled', false) ->assertJsonPath('cluster.servers.0.builderCapacity', 5); $server->refresh(); expect($server->builder_enabled)->toBeFalse() ->and($server->builder_capacity)->toBe(5) ->and($server->builder_cpu_quota)->toBe('350%') ->and($server->capabilities)->toBe(['coold']); }); 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('requires positive editable v5 server builder capacity when builder is enabled', 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' => '200%', ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ 'builder_enabled' => true, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', ]) ->assertUnprocessable() ->assertJsonValidationErrors(['builder_capacity']); }); 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()') ->toContain("import { csrfToken } from '@/lib/csrf';") ->toContain("import { Button } from '@/components/ui/button';") ->not->toContain("fetch('/v5/clusters'") ->toContain('toContain('bg-background text-foreground') ->toContain('h-dvh overflow-hidden bg-background text-foreground') ->toContain('relative h-full min-h-0 overflow-hidden pt-16') ->toContain('Add nginx') ->toContain('Select nginx server') ->toContain('selectedNginxServerId') ->toContain('server_id: selectedNginxServerId || null') ->toContain('Center') ->toContain('Delete') ->toContain("method: 'DELETE'") ->toContain('removeApplication') ->toContain('useEffect(() => {') ->toContain('setApplications(settledResources.applications);') ->toContain('centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);') ->toContain('Caddy ingress') ->toContain('persistCaddyIngressPosition') ->toContain('startIngressDrag') ->toContain('fetch(`/v5/caddy-ingresses/${ingress.id}/position`') ->toContain("fetch('/v5/applications/nginx'") ->toContain('nginxServers = []') ->toContain('fetch(`/v5/applications/${application.id}/position`') ->not->toContain('not->toContain('h-[calc(100dvh-4rem)]') ->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground') ->toContain('No applications on this canvas yet.') ->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';") ->not->toContain("fetch('/v5/selection'"); expect($navbar) ->toContain("import { Link, router, 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('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('hover:bg-muted') ->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('') ->toContain('toContain('toContain('') ->toContain('Coolify') ->toContain('') ->toContain('toContain('Move between Coolify v5 pages.') ->not->toContain('Navigation') ->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('router.reload({') ->toContain("only: ['applications', 'selectedProjectUuid', 'selectedEnvironmentUuid']") ->toContain('void persistSelection(nextProjectUuid, nextEnvironmentUuid).then(refreshCurrentPageSelection);') ->toContain('void persistSelection(projectUuid, nextEnvironmentUuid).then(refreshCurrentPageSelection);') ->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('not->toContain("import { Button } from '@/components/ui/button';") ->not->toContain('not->toContain("import { Separator } from '@/components/ui/separator';") ->not->toContain('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('

Coolify v5

') ->not->toContain('

Clusters

'); 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()') ->toContain('const isCheckingServer = checkingServers.has(server.id)') ->toContain('checkingServers.start(server.id)') ->toContain('checkingServers.finish(server.id)') ->not->toContain('const [checkingServerId, setCheckingServerId] = useState(null)') ->not->toContain('setCheckingServerId(server.id)') ->not->toContain('setCheckingServerId(null)'); expect($pendingIdsHook) ->toContain('export function usePendingIds') ->toContain('const [pendingIds, setPendingIds] = useState>(() => 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(); preg_match('/not->toBeEmpty(); expect($clustersPage) ->toContain("import { Button } from '@/components/ui/button';") ->toContain("} from '@/components/ui/dialog';") ->toContain("} from '@/components/ui/dropdown-menu';") ->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';") ->toContain("import { csrfToken } from '@/lib/csrf';") ->toContain("fetch('/v5/clusters'") ->toContain('aria-label="Select a cluster"') ->toContain('Cluster state') ->not->toContain('Last run') ->not->toContain('selectedCluster.lastCliStatus') ->not->toContain('selectedCluster.lastCliSummary') ->toContain('Generated mesh values are saved after bootstrap or extend runs.') ->not->toContain('CLI state') ->not->toContain('CLI-generated mesh values') ->toContain('Select a cluster') ->toContain('setSelectedClusterId(value)') ->toContain('aria-label="Create cluster"') ->toContain('setIsCreateDialogOpen(true)') ->toContain('Add cluster') ->toContain('variant="coolify"') ->not->toContain('border-warning bg-warning/10 text-foreground') ->not->toContain('border-primary bg-primary/10 text-foreground') ->toContain('toContain('Create cluster') ->toContain('') ->toContain('') ->not->toContain('toContain('Cancel') ->toContain('') ->toContain('Confirm deletion') ->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('Add server') ->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('fetch(`/v5/clusters/${selectedCluster.id}`') ->toContain('hasBootstrapInProgress') ->toContain('Bootstrap') ->toContain('lastBootstrapOutput') ->toContain('Unable to queue bootstrap for this server.') ->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('') ->toContain('toContain("import { DotsThreeIcon } from '@phosphor-icons/react';") ->toContain('variant="ghost"') ->toContain('size="icon-sm"') ->toContain('aria-label="Server actions"') ->toContain('') ->not->toContain('>Server actions') ->toContain('') ->toContain('') ->toContain('Check connection') ->not->toContain('Check SSH') ->toContain('Not initialized') ->toContain('role="group"') ->toContain('aria-label="Server initialization"') ->toContain('rounded-l-md border border-r-0 border-destructive/30 bg-destructive/10') ->toContain('variant="coolify"') ->toContain('className="rounded-r-md"') ->toContain('onClick={() => void bootstrapServer(server)}') ->not->toContain('not->toContain('Bootstrap: {serverStatusLabel(server.status)}') ->not->toContain('Last bootstrap') ->not->toContain('{formatDate(server.lastBootstrappedAt)}') ->toContain('Latest SSH check: {latestSshCheck.status}') ->toContain('const notInitializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt === null) ?? []') ->toContain('const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? []') ->toContain('Not initialized servers') ->toContain('{notInitializedServers.map(renderServerCard)}') ->toContain('{initializedServers.map(renderServerCard)}') ->toContain('{!isServerInitialized ? (') ->toContain('Show install logs') ->toContain('{canShowBootstrapLogs ? (') ->not->toContain('{!isServerInitialized && isBootstrapLogVisible ? (') ->toContain('Delete server') ->toContain('This removes it from this cluster inventory so you can add it again later.') ->not->toContain('toContain('Edit server') ->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('Node address override') ->toContain('Defaults to server IP') ->not->toContain('CLI node address') ->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('Server IP') ->toContain('{server.host}') ->not->toContain('
CLI node
') ->not->toContain('CLI node') ->not->toContain('{server.nodeAddress ?? server.host}') ->toContain('builderCapacity') ->toContain('{server.builderEnabled ? (') ->toContain('
Builder CPU quota
') ->toContain('server.builderCpuQuota') ->toContain(') : null}') ->toContain('privateKeyName') ->toContain('lastBootstrappedAt') ->toContain('This removes it from this cluster inventory so you can add it again later.') ->toContain('deleteCluster') ->toContain('fetch(`/v5/clusters/${cluster.id}`') ->toContain("method: 'DELETE'") ->toContain('Delete cluster') ->toContain('selectedCluster.serversCount === 0') ->not->toContain("selectedCluster.serversCount === 1 ? 'server' : 'servers'") ->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 w-full flex-col gap-4 py-4 lg:min-h-0 lg:py-6') ->toContain('rounded-lg border border-border bg-card p-4') ->toContain('flex items-start justify-between gap-3') ->toContain('min-w-0 flex-1') ->toContain('flex shrink-0 items-center justify-end gap-2 sm:flex-wrap') ->toContain('aria-label="Select a cluster"') ->toContain('setSelectedClusterId(value)') ->not->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') ->not->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') ->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]') ->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]') ->not->toContain('New cluster') ->not->toContain('