0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test', ]); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); $this->sensitiveToken = $this->user->createToken('transfer-sensitive', ['*', 'read:sensitive'])->plainTextToken; $this->readToken = $this->user->createToken('transfer-read', ['read'])->plainTextToken; $this->writeToken = $this->user->createToken('transfer-write', ['read', 'write'])->plainTextToken; $this->importToken = $this->user->createToken('transfer-import', ['read', 'write', 'deploy'])->plainTextToken; $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $this->server = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.66.0.20', 'name' => 'api-transfer-server', ]); $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = $this->project->environments()->first() ?? Environment::factory()->create(['project_id' => $this->project->id]); $this->application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'name' => 'api-app', 'git_repository' => 'https://github.com/example/api-app', 'git_branch' => 'main', 'build_pack' => 'nixpacks', 'ports_exposes' => '8080', ]); EnvironmentVariable::withoutEvents(function () { $env = new EnvironmentVariable; $env->forceFill([ 'key' => 'API_TOKEN', 'value' => 'token-value-123', 'resourceable_type' => Application::class, 'resourceable_id' => $this->application->id, 'is_preview' => false, 'is_runtime' => true, 'is_buildtime' => true, ]); $env->uuid = new_public_id(); $env->save(); }); }); test('server transfer API is available in production', function () { config(['app.env' => 'production']); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/transfer") ->assertOk() ->assertJsonPath('server_uuid', $this->server->uuid); }); function transferExportForApiTest(Server $server): array { return app(ServerTransferExporter::class)->export($server); } function transferHeaders(string $token): array { return [ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', 'Content-Type' => 'application/json', ]; } test('the manual export endpoints no longer exist', function (string $path) { $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}{$path}", ['passphrase' => 'correct horse battery']) ->assertNotFound(); })->with(['/export', '/export/mailbox']); describe('POST /api/v1/servers/import', function () { test('dry run does not create resources', function () { $export = transferExportForApiTest($this->server); $before = Server::count(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', [ 'bundle' => $export, 'dry_run' => true, ]) ->assertOk() ->assertJsonPath('dry_run', true) ->assertJsonPath('created.applications', 1); expect(Server::count())->toBe($before); }); test('imports after source handoff and preserves application uuid', function () { $export = transferExportForApiTest($this->server); $appUuid = $this->application->uuid; $serverUuid = $this->server->uuid; $this->application->forceDelete(); $this->server->forceDelete(); $this->privateKey->delete(); $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', [ 'bundle' => $export, 'dry_run' => false, 'preserve_uuids' => true, 'adopt_mode' => true, ]); $response->assertCreated() ->assertJsonPath('server_uuid', $serverUuid) ->assertJsonPath('created.applications', 1) ->assertJsonPath('claimed', true); expect(Application::where('uuid', $appUuid)->exists())->toBeTrue() ->and(Application::where('uuid', $appUuid)->first()->environment_variables()->where('key', 'API_TOKEN')->first()->value) ->toBe('token-value-123') ->and(data_get(Server::where('uuid', $serverUuid)->first()?->server_metadata, 'transfer.status')) ->toBe('claimed'); }); test('keeps whitespace and empty strings of the bundle values', function () { $this->application->forceFill(['dockerfile' => "FROM nginx:alpine\nEXPOSE 80\n", 'build_pack' => 'dockerfile'])->saveQuietly(); LocalFileVolume::withoutEvents(fn () => LocalFileVolume::forceCreate([ 'uuid' => new_public_id(), 'fs_path' => '/data/coolify/applications/app.yml', 'mount_path' => '/etc/app.yml', 'content' => " indented: true\nlast: line\n", 'resource_type' => Application::class, 'resource_id' => $this->application->id, ])); SharedEnvironmentVariable::create(['key' => 'EMPTY_ON_SERVER', 'value' => '', 'type' => 'server', 'server_id' => $this->server->id, 'team_id' => $this->team->id]); $export = transferExportForApiTest($this->server); $appUuid = $this->application->uuid; $this->application->forceDelete(); $this->server->forceDelete(); $this->privateKey->delete(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertCreated(); $imported = Application::where('uuid', $appUuid)->firstOrFail(); expect($imported->dockerfile)->toBe("FROM nginx:alpine\nEXPOSE 80\n") ->and($imported->fileStorages()->value('content'))->toBe(" indented: true\nlast: line\n") ->and(SharedEnvironmentVariable::where('key', 'EMPTY_ON_SERVER')->first()?->value)->toBe(''); }); test('rejects import when ip still exists', function () { $export = transferExportForApiTest($this->server); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(422) ->assertJsonValidationErrors(['server.uuid']); }); test('does not reveal the server of another team when the ip exists', function () { $export = transferExportForApiTest($this->server); $this->application->forceDelete(); $this->server->forceDelete(); $otherServer = Server::factory()->create([ 'team_id' => Team::factory()->create()->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.66.0.20', ]); $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(422); expect($response->getContent())->toContain('already exists') ->not->toContain($otherServer->uuid); }); test('rejects a bundle from a newer Coolify version', function () { $export = transferExportForApiTest($this->server); $export['coolify_version'] = '99.0.0'; $this->application->forceDelete(); $this->server->forceDelete(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(422) ->assertJsonPath('errors.bundle.0', fn ($message) => str_contains($message, 'Upgrade this instance')); expect(Server::where('uuid', $export['server']['uuid'])->exists())->toBeFalse(); }); test('adopt mode requires the original uuids', function () { $export = transferExportForApiTest($this->server); $this->application->forceDelete(); $this->server->forceDelete(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export, 'preserve_uuids' => false, 'adopt_mode' => true]) ->assertStatus(422) ->assertJsonValidationErrors(['preserve_uuids']); }); test('on Cloud, rate limits the SSH validation that the import starts', function () { config()->set('constants.coolify.self_hosted', false); $this->team->update(['custom_server_limit' => 10]); InstanceSettings::findOrFail(0)->forceFill(['server_validation_max_failures_per_ip' => 1])->save(); Server::throttleValidation($this->team->id, '10.66.0.20'); $export = transferExportForApiTest($this->server); $this->server->forceDelete(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(429); expect(Server::where('uuid', $export['server']['uuid'])->exists())->toBeFalse(); Queue::assertNotPushed(ValidateAndInstallServerJob::class); }); test('rejects a database entry that names a model outside the database types', function () { $otherTeam = Team::factory()->create(); $otherKey = PrivateKey::factory()->create(['team_id' => $otherTeam->id]); $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/export") ->json(); $this->application->forceDelete(); $this->server->forceDelete(); $this->privateKey->delete(); data_set($export, 'projects.0.environments.0.databases', [[ 'type' => 'NotADatabase', 'model' => Application::class, 'attributes' => [ 'name' => 'injected-app', 'git_repository' => 'git@github.com:victim/private.git', 'git_branch' => 'main', 'build_pack' => 'nixpacks', 'ports_exposes' => '80', 'private_key_id' => $otherKey->id, ], ]]); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export, 'dry_run' => false]) ->assertStatus(422); expect(Application::where('name', 'injected-app')->exists())->toBeFalse(); }); test('rejects import when the destination team is at its cloud server limit', function () { config()->set('constants.coolify.self_hosted', false); $this->team->update(['custom_server_limit' => 1]); $export = transferExportForApiTest($this->server); $this->server->forceDelete(); Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.66.0.21', ]); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(422) ->assertJsonPath('errors.server.0', 'Server limit reached for your subscription.'); expect(Server::where('team_id', $this->team->id)->count())->toBe(1); }); }); describe('POST /api/v1/servers/import with an unsafe bundle', function () { test('rejects the whole bundle before it writes anything', function (Closure $tamper, string $error) { $export = transferExportForApiTest($this->server); $this->application->forceDelete(); $this->server->forceDelete(); $tamper($export); $counts = fn () => [Server::withTrashed()->count(), PrivateKey::count(), Project::count(), Application::withTrashed()->count(), GithubApp::count()]; $before = $counts(); $response = $this->withHeaders(transferHeaders($this->importToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(422); expect(collect($response->json('errors.bundle'))->contains(fn (string $message) => str_contains($message, $error)))->toBeTrue((string) $response->getContent()) ->and($counts())->toBe($before); Queue::assertNotPushed(ValidateAndInstallServerJob::class); Process::assertNothingRan(); })->with([ 'server uuid with shell syntax' => [function (array &$bundle) { $bundle['server']['uuid'] = 'x;id;#'; }, 'server.uuid is not a valid id'], 'private key uuid with command substitution' => [function (array &$bundle) { $bundle['private_key']['uuid'] = 'a $(id)'; }, 'private_key.uuid is not a valid id'], 'project uuid with path traversal' => [function (array &$bundle) { $bundle['projects'][0]['uuid'] = '../x'; }, 'projects.0.uuid is not a valid id'], 'reference uuid with path traversal' => [function (array &$bundle) { $bundle['projects'][0]['environments'][0]['applications'][0]['destination_uuid'] = '../../etc'; }, 'destination_uuid is not a valid id'], 'javascript git source url' => [function (array &$bundle) { $bundle['github_apps'] = [['uuid' => 'evil-app', 'name' => 'evil', 'html_url' => 'javascript:alert(1)', 'api_url' => 'https://api.github.com']]; }, 'github_apps.0.html_url'], 'invalid application fqdn' => [function (array &$bundle) { $bundle['projects'][0]['environments'][0]['applications'][0]['attributes']['fqdn'] = 'javascript:alert(document.cookie)'; }, 'applications.0.fqdn'], 'invalid scheduled task cron' => [function (array &$bundle) { $bundle['projects'][0]['environments'][0]['applications'][0]['scheduled_tasks'] = [['name' => 'task', 'command' => 'echo hi', 'frequency' => 'every full moon']]; }, 'scheduled_tasks.0.frequency is not a valid cron expression'], 'storage path with shell syntax' => [function (array &$bundle) { $bundle['projects'][0]['environments'][0]['applications'][0]['persistent_storages'] = [['name' => 'data', 'mount_path' => '/data;id', 'host_path' => null]]; }, 'persistent_storages.0.mount_path'], 'destination network with shell syntax' => [function (array &$bundle) { $bundle['destinations'][0]['network'] = 'coolify;id'; }, 'destinations.0.network'], 'too many warnings' => [function (array &$bundle) { $bundle['warnings'] = array_fill(0, 1001, 'x'); }, 'A bundle can have at most 1000 warnings'], ]); test('a uuid that another team uses does not say that the other team has it', function () { $export = transferExportForApiTest($this->server); $appUuid = $this->application->uuid; $this->server->forceDelete(); $this->application->forceFill(['environment_id' => Environment::factory()->create([ 'project_id' => Project::factory()->create(['team_id' => Team::factory()->create()->id])->id, ])->id])->saveQuietly(); $response = $this->withHeaders(transferHeaders($this->importToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(422); expect($response->json('errors.bundle.0'))->toBe("The application UUID {$appUuid} cannot be used on this instance. Adopt mode needs the same UUID to keep the running containers. If you deleted the application on this instance, delete it permanently first."); }); }); describe('POST /api/v1/servers/import claim', function () { test('import takes management of the server and rebinds Sentinel', function () { $export = transferExportForApiTest($this->server); $this->application->forceDelete(); $this->server->forceDelete(); $this->privateKey->delete(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertCreated() ->assertJsonPath('claimed', true) ->assertJsonPath('claim.claim.instance_url', 'https://coolify-a.test'); $server = Server::where('uuid', $export['server']['uuid'])->first(); expect(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed') ->and((bool) $server->settings->is_sentinel_enabled)->toBeTrue() ->and($server->settings->sentinel_custom_url)->toBe('https://coolify-a.test') ->and($server->settings->sentinel_token)->not->toBeEmpty(); }); test('claim endpoint is removed', function () { $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/claim") ->assertNotFound(); }); test('import rejects the removed claim options', function () { $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => [], 'write_remote' => true]) ->assertStatus(422) ->assertJsonValidationErrors(['write_remote']); }); }); describe('POST /api/v1/servers/{uuid}/transfer/complete', function () { test('marks source server transferred and force disables it', function () { $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [ 'export_id' => 'exp-123', 'target_instance_url' => 'https://8.8.8.8', ]); $response->assertOk() ->assertJsonPath('server_uuid', $this->server->uuid); $server = $this->server->fresh(['settings']); expect($server->settings->force_disabled)->toBeTrue() ->and(data_get($server->server_metadata, 'transfer.status'))->toBe('transferred') ->and(data_get($server->server_metadata, 'transfer.export_id'))->toBe('exp-123') ->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://8.8.8.8'); }); }); describe('POST /api/v1/servers/{uuid}/transfer/complete with an unsafe URL', function () { test('rejects a target instance URL that a transfer back must not send a token to', function (string $url) { $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", ['target_instance_url' => $url]) ->assertStatus(422) ->assertJsonValidationErrors(['target_instance_url']); expect($this->server->fresh()->isTransferredAway())->toBeFalse(); })->with([ 'javascript' => 'javascript:alert(1)', 'plain http' => 'http://8.8.8.8', 'private address' => 'https://10.0.0.5', ]); }); describe('GET /api/v1/servers/{uuid}/transfer for a member', function () { test('shows the transfer state, but not its log and error', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); $memberToken = $member->createToken('member-read', ['read'], teamId: $this->team->id)->plainTextToken; $this->server->forceFill(['server_metadata' => ['migration' => [ 'status' => 'failed', 'target_url' => 'https://8.8.8.8', 'error' => 'secret error', 'log' => [['at' => now()->toIso8601String(), 'level' => 'error', 'message' => 'secret log']], ]]])->save(); $this->withHeaders(transferHeaders($memberToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/transfer") ->assertOk() ->assertJsonPath('migration.status', 'failed') ->assertJsonMissingPath('migration.log') ->assertJsonMissingPath('migration.error'); app('auth')->forgetGuards(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/transfer") ->assertOk() ->assertJsonPath('migration.error', 'secret error') ->assertJsonPath('migration.log.0.message', 'secret log'); }); }); describe('full transfer flow A to B on same process', function () { test('export complete import sequence', function () { $export = transferExportForApiTest($this->server); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [ 'export_id' => $export['export_id'], 'target_instance_url' => 'https://8.8.8.8', ]) ->assertOk(); // Source freed: force delete disabled server + key so import can recreate. $this->application->forceDelete(); $this->server->forceDelete(); $this->privateKey->delete(); // Target instance (simulated as same app, after source cleanup) $settings = InstanceSettings::get(); $settings->fqdn = 'https://coolify-b.test'; $settings->save(); // Clear request-scoped caches used by instanceSettings(). if (function_exists('once')) { Once::flush(); } $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', [ 'bundle' => $export, 'preserve_uuids' => true, 'adopt_mode' => true, ]) ->assertCreated() ->assertJsonPath('claimed', true); $server = Server::where('uuid', $export['server']['uuid'])->first(); expect($server)->not->toBeNull() ->and($server->settings->force_disabled)->toBeFalse() ->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed') ->and($server->settings->sentinel_custom_url)->toBe('https://coolify-b.test'); }); }); describe('POST /api/v1/servers/{uuid}/migrate', function () { test('starts the transfer in a queued job', function () { Queue::fake(); Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ 'target_url' => 'https://8.8.8.8', 'target_token' => 'target-token', ]) ->assertStatus(202) ->assertJsonPath('migration.status', 'running'); Queue::assertPushed(MigrateServerToInstanceJob::class, fn ($job) => $job->server->is($this->server) && $job->targetToken === 'target-token'); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/transfer") ->assertOk() ->assertJsonPath('migration.status', 'running') ->assertJsonPath('migration.target_url', 'https://8.8.8.8'); }); test('rejects a team admin, because a transfer sends every secret to the target', function () { Queue::fake(); Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]); $admin = User::factory()->create(); $this->team->members()->attach($admin->id, ['role' => 'admin']); $adminToken = $admin->createToken('admin-sensitive', ['*', 'read:sensitive'], teamId: $this->team->id)->plainTextToken; $this->withHeaders(transferHeaders($adminToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ 'target_url' => 'https://8.8.8.8', 'target_token' => 'target-token', ]) ->assertForbidden(); Queue::assertNothingPushed(); expect(data_get($this->server->fresh()->server_metadata, 'migration'))->toBeNull(); }); test('rejects a target without https', function () { Queue::fake(); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ 'target_url' => 'http://8.8.8.8', 'target_token' => 'target-token', ]) ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'https')); Queue::assertNothingPushed(); }); test('rejects a transfer while a deployment runs on the server', function () { Queue::fake(); ApplicationDeploymentQueue::create([ 'application_id' => (string) $this->application->id, 'deployment_uuid' => new_public_id(), 'server_id' => $this->server->id, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ 'target_url' => 'https://8.8.8.8', 'target_token' => 'target-token', ]) ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'deployment')); Queue::assertNothingPushed(); }); }); describe('GET /api/v1/servers/import/check', function () { test('accepts a server that this instance can import and reach', function () { $listener = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") ->assertOk() ->assertJsonPath('checks', fn ($checks) => collect($checks)->every(fn ($check) => $check['ok'] === true) && collect($checks)->pluck('label')->contains("the target can reach the SSH port of the server (127.0.0.1:{$port})")); fclose($listener); }); test('rejects a server whose SSH port this instance cannot reach', function () { $listener = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); fclose($listener); $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'cannot reach the server at 127.0.0.1')); }); test('rejects a target that is not an IP address or hostname before it opens a socket', function (string $ip) { $this->withHeaders(transferHeaders($this->importToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip='.urlencode($ip)) ->assertStatus(422) ->assertJsonValidationErrors('ip') ->assertJsonMissingPath('checks'); })->with(['tcp://127.0.0.1', 'unix:///var/run/docker.sock', 'ssl://example.com']); test('rejects another transfer bundle version', function () { $this->withHeaders(transferHeaders($this->importToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&schema_version=999') ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'Upgrade both instances')); }); test('lists every check with its result when a server already exists on this instance', function () { $listener = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); $this->server->forceFill(['ip' => '127.0.0.1', 'port' => $port])->save(); $checks = $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'already exists on this instance')) ->json('checks'); expect(collect($checks)->pluck('ok', 'label')->all())->toMatchArray([ 'the transfer bundle version is supported' => true, 'no server with the same UUID exists' => true, 'no server with the same IP and SSH port exists' => false, "the target can reach the SSH port of the server (127.0.0.1:{$port})" => true, ]); fclose($listener); }); test('accepts a server with the IP of an existing server and a different SSH port', function () { $listener = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); $this->server->forceFill(['ip' => '127.0.0.1', 'port' => 22])->save(); $checks = $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") ->assertOk() ->json('checks'); expect(collect($checks)->pluck('ok', 'label')->get('no server with the same IP and SSH port exists'))->toBeTrue(); fclose($listener); }); test('on Cloud, does not probe private, loopback, or link-local addresses', function (string $ip) { config()->set('constants.coolify.self_hosted', false); $this->withHeaders(transferHeaders($this->importToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip='.urlencode($ip).'&port=22') ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'is a private, loopback, or link-local address')); })->with(['127.0.0.1', '10.0.0.5', '169.254.169.254', '::1', 'localhost']); test('on Cloud, rate limits the reachability probe', function () { config()->set('constants.coolify.self_hosted', false); InstanceSettings::findOrFail(0)->forceFill(['server_validation_max_failures_per_team' => 2])->save(); foreach (['10.0.0.5', '10.0.0.6'] as $ip) { $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip={$ip}") ->assertStatus(422); } $this->withHeaders(transferHeaders($this->importToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.0.0.7') ->assertStatus(429); }); test('rejects a token without the write permission', function () { $this->withHeaders(transferHeaders($this->readToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') ->assertForbidden(); }); test('rejects a token without the deploy permission that the import needs', function () { $this->withHeaders(transferHeaders($this->writeToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') ->assertForbidden(); }); test('rejects an invalid token', function () { $this->withHeaders(transferHeaders('1|not-a-real-token')) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') ->assertUnauthorized(); }); }); test('the import check lists the system-wide git sources that this instance does not have', function () { $present = GithubApp::create(['name' => 'present', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 0, 'is_system_wide' => true]); $listener = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}&system_github_apps[]={$present->uuid}&system_github_apps[]=missing-uuid&system_gitlab_apps[]=other-missing") ->assertOk() ->assertJsonPath('missing_system_sources.github', ['missing-uuid']) ->assertJsonPath('missing_system_sources.gitlab', ['other-missing']); fclose($listener); });