mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Closes #7573 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
724 lines
33 KiB
PHP
724 lines
33 KiB
PHP
<?php
|
|
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Jobs\MigrateServerToInstanceJob;
|
|
use App\Jobs\ValidateAndInstallServerJob;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Models\GithubApp;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\SharedEnvironmentVariable;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\ServerTransfer\ServerTransferExporter;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Once;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
// A claim writes the owner marker on the host over SSH.
|
|
Process::fake();
|
|
// An import validates the claimed server over SSH; the sync test queue would run it.
|
|
Queue::fake([ValidateAndInstallServerJob::class]);
|
|
Storage::fake('ssh-keys');
|
|
|
|
InstanceSettings::forceCreate([
|
|
'id' => 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);
|
|
});
|