mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 10:18:20 +00:00
Merge remote-tracking branch 'origin/next' into feat/noindex-domains
This commit is contained in:
commit
003e44d123
1274 files changed
+156179
-25249
No files matched your search
@@ -18,6 +18,20 @@ it('normalizes requested log line counts', function () {
|
||||
->and(normalizeLogLines('50000'))->toBe(10000);
|
||||
});
|
||||
|
||||
it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) {
|
||||
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
|
||||
|
||||
expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));')
|
||||
->and(normalizeLogLines($lines))->toBe($expectedLines);
|
||||
})->with([
|
||||
'application invalid value' => ['ServiceApplicationsController', 'invalid', 100],
|
||||
'application negative value' => ['ServiceApplicationsController', '-5', 100],
|
||||
'application value above limit' => ['ServiceApplicationsController', '50000', 10000],
|
||||
'database invalid value' => ['ServiceDatabasesController', 'invalid', 100],
|
||||
'database negative value' => ['ServiceDatabasesController', '-5', 100],
|
||||
'database value above limit' => ['ServiceDatabasesController', '50000', 10000],
|
||||
]);
|
||||
|
||||
it('parses show_timestamps query values as booleans', function () {
|
||||
if (! function_exists('parseLogTimestampFlag')) {
|
||||
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
it('respects the instance SPA navigation setting after application actions', function () {
|
||||
$heading = file_get_contents(__DIR__.'/../../app/Livewire/Project/Application/Heading.php');
|
||||
|
||||
expect(substr_count($heading, "return redirectRoute(\$this, 'project.application.deployment.show'"))
|
||||
->toBe(2)
|
||||
->and($heading)
|
||||
->not->toContain('navigate: false');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use OpenApi\Attributes\Property;
|
||||
use OpenApi\Attributes\Schema;
|
||||
|
||||
it('documents application settings under the settings property', function () {
|
||||
$schema = (new ReflectionClass(Application::class))
|
||||
->getAttributes(Schema::class)[0]
|
||||
->newInstance();
|
||||
|
||||
$openApi = json_decode(
|
||||
file_get_contents(__DIR__.'/../../openapi.json'),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
$settingsProperty = collect($schema->properties)
|
||||
->first(fn (mixed $property): bool => $property instanceof Property
|
||||
&& $property->property === 'settings');
|
||||
|
||||
expect($settingsProperty)
|
||||
->not->toBeNull()
|
||||
->and($openApi['components']['schemas']['Application']['properties'])
|
||||
->toHaveKey('settings')
|
||||
->not->toHaveKey('');
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Stringable;
|
||||
|
||||
/**
|
||||
* Unit tests to verify that the applicationParser function in parsers.php
|
||||
* properly converts Stringable objects to plain strings to fix strict
|
||||
@@ -26,8 +28,8 @@ it('ensures service name normalization returns plain strings not Stringable obje
|
||||
// Verify both are plain strings, not Stringable objects
|
||||
expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string');
|
||||
expect(is_string($serviceName))->toBeTrue('$serviceName should be a plain string');
|
||||
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($serviceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($originalServiceName)->not->toBeInstanceOf(Stringable::class);
|
||||
expect($serviceName)->not->toBeInstanceOf(Stringable::class);
|
||||
|
||||
// Verify the transformations work correctly
|
||||
expect($originalServiceName)->toBe('my-service');
|
||||
@@ -135,7 +137,7 @@ it('ensures originalServiceName conversion works for FQDN generation', function
|
||||
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||
|
||||
expect(is_string($originalServiceName))->toBeTrue();
|
||||
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($originalServiceName)->not->toBeInstanceOf(Stringable::class);
|
||||
expect($originalServiceName)->toBe('my-service');
|
||||
|
||||
// Verify it can be used in string interpolation (line 544)
|
||||
@@ -177,6 +179,9 @@ it('verifies parsers.php has the ->value() calls', function () {
|
||||
// Line 539: Check originalServiceName conversion
|
||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()");
|
||||
|
||||
// Line 541: Check serviceName normalization
|
||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('-', '_')->replace('.', '_')->value()");
|
||||
// Service name normalization for env keys / domain map (helper-based)
|
||||
expect($parsersFile)->toContain('normalizeComposeServiceName((string) $serviceName)')
|
||||
->and($parsersFile)->toContain('getComposeServiceDomainString')
|
||||
->and($parsersFile)->toContain('putComposeServiceDomain')
|
||||
->and($parsersFile)->toContain('findServiceApplicationForEnvName');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
function backupDownloadServer(): Server
|
||||
{
|
||||
$server = new Server;
|
||||
$server->forceFill([
|
||||
'ip' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'user' => 'root',
|
||||
]);
|
||||
|
||||
$privateKey = Mockery::mock(PrivateKey::class);
|
||||
$privateKey->shouldReceive('getKeyLocation')->andReturn('/tmp/private-key');
|
||||
$server->setRelation('privateKey', $privateKey);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('throws when the backup file does not exist on the server', function () {
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturnFalse();
|
||||
|
||||
Storage::shouldReceive('build')
|
||||
->once()
|
||||
->with([
|
||||
'driver' => 'sftp',
|
||||
'host' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'username' => 'root',
|
||||
'privateKey' => '/tmp/private-key',
|
||||
'root' => '/',
|
||||
])
|
||||
->andReturn($disk);
|
||||
|
||||
streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
})->throws(FileNotFoundException::class);
|
||||
|
||||
it('streams a backup file with the requested content type', function () {
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
fwrite($stream, 'backup contents');
|
||||
rewind($stream);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')->once()->andReturnTrue();
|
||||
$disk->shouldReceive('readStream')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturn($stream);
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
|
||||
$response = streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class)
|
||||
->and($response->headers->get('content-type'))->toBe('application/gzip')
|
||||
->and($response->headers->get('content-disposition'))->toBe('attachment; filename="archive.tar.gz"');
|
||||
|
||||
ob_start();
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$contents = ob_get_clean();
|
||||
|
||||
expect($contents)->toBe('backup contents');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
it('includes a production-ready Buzz one-click service template', function () {
|
||||
$compose = file_get_contents(__DIR__.'/../../templates/compose/buzz.yaml');
|
||||
|
||||
expect($compose)
|
||||
->toContain('ghcr.io/block/buzz:${BUZZ_TAG:-main}')
|
||||
->toContain('SERVICE_URL_BUZZ_3000')
|
||||
->toContain('RELAY_URL=wss://${SERVICE_FQDN_BUZZ}')
|
||||
->toContain('BUZZ_RELAY_PRIVATE_KEY=${SERVICE_HEX_64_RELAYKEY}')
|
||||
->toContain('BUZZ_AUTO_MIGRATE=${BUZZ_AUTO_MIGRATE:-true}')
|
||||
->toContain('minio-init')
|
||||
->toContain('exclude_from_hc: true')
|
||||
->toContain('postgres:17-alpine')
|
||||
->toContain('redis:7-alpine');
|
||||
|
||||
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
|
||||
$templates = json_decode(
|
||||
file_get_contents(__DIR__."/../../templates/{$templateFile}"),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
expect($templates)->toHaveKey('buzz');
|
||||
expect($templates['buzz']['port'] ?? null)->toBe('3000');
|
||||
expect($templates['buzz']['logo'] ?? null)->toBe('svgs/buzz.svg');
|
||||
expect($templates['buzz']['category'] ?? null)->toBe('messaging');
|
||||
|
||||
$generatedCompose = base64_decode($templates['buzz']['compose'], strict: true);
|
||||
|
||||
expect($generatedCompose)
|
||||
->toContain('ghcr.io/block/buzz:${BUZZ_TAG:-main}')
|
||||
->toContain('wss://');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
it('includes a celld one-click service template', function () {
|
||||
$compose = file_get_contents(__DIR__.'/../../templates/compose/celld.yaml');
|
||||
|
||||
expect($compose)
|
||||
->toContain('ghcr.io/denoland/celld:${CELLD_VERSION:-latest}')
|
||||
->toContain('SERVICE_URL_CELLD_8080')
|
||||
->toContain('CELLD_BUCKET=${CELLD_BUCKET:?}')
|
||||
->toContain('S3_ENDPOINT=${S3_ENDPOINT:-}')
|
||||
->toContain('CELLD_ADDR=0.0.0.0:8080')
|
||||
->toContain('CELLD_ADVERTISE=celld:8080')
|
||||
->toContain('celld-data:/var/lib/celld');
|
||||
|
||||
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
|
||||
$templates = json_decode(
|
||||
file_get_contents(__DIR__."/../../templates/{$templateFile}"),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
expect($templates)->toHaveKey('celld');
|
||||
expect($templates['celld']['port'] ?? null)->toBe('8080');
|
||||
expect($templates['celld']['logo'] ?? null)->toBe('svgs/denoKV.svg');
|
||||
expect($templates['celld']['category'] ?? null)->toBe('backend');
|
||||
|
||||
$generatedCompose = base64_decode($templates['celld']['compose'], strict: true);
|
||||
|
||||
expect($generatedCompose)
|
||||
->toContain('ghcr.io/denoland/celld:${CELLD_VERSION:-latest}')
|
||||
->toContain('CELLD_BUCKET=${CELLD_BUCKET:?}');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Support\ClickhouseBackupCommand;
|
||||
|
||||
test('clickhouse exposes scheduled backups', function () {
|
||||
expect((new StandaloneClickhouse)->isBackupSolutionAvailable())->toBeTrue();
|
||||
});
|
||||
|
||||
test('builds a native clickhouse backup command using container credentials', function () {
|
||||
$commands = ClickhouseBackupCommand::make(
|
||||
containerName: 'clickhouse-uuid',
|
||||
database: 'analytics',
|
||||
archiveName: 'backup-uuid.zip',
|
||||
backupDirectory: '/data/coolify/backups/clickhouse',
|
||||
);
|
||||
|
||||
expect($commands)->toHaveCount(3)
|
||||
->and($commands[0])->toBe("mkdir -p '/data/coolify/backups/clickhouse'")
|
||||
->and($commands[1])->toContain("docker exec 'clickhouse-uuid' clickhouse-client")
|
||||
->and($commands[1])->not->toContain('--password')
|
||||
->and($commands[1])->toContain("BACKUP DATABASE `analytics` TO File('")
|
||||
->and($commands[1])->toContain('backup-uuid.zip')
|
||||
->and($commands[2])->toBe("docker cp 'clickhouse-uuid:/var/lib/clickhouse/backups/backup-uuid.zip' '/data/coolify/backups/clickhouse/backup-uuid.zip'")
|
||||
->and(ClickhouseBackupCommand::cleanup('clickhouse-uuid', 'backup-uuid.zip'))
|
||||
->toBe("docker exec 'clickhouse-uuid' rm -f '/var/lib/clickhouse/backups/backup-uuid.zip'");
|
||||
});
|
||||
|
||||
test('rejects unsafe clickhouse database names', function () {
|
||||
expect(fn () => ClickhouseBackupCommand::make(
|
||||
containerName: 'clickhouse-uuid',
|
||||
database: 'analytics`; DROP DATABASE default; --',
|
||||
archiveName: 'backup-uuid.zip',
|
||||
backupDirectory: '/data/coolify/backups/clickhouse',
|
||||
))->toThrow(Exception::class);
|
||||
});
|
||||
@@ -56,11 +56,10 @@ it('shows explicit loading spinners and disables cloud token action buttons whil
|
||||
$tokenShowView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token/show.blade.php');
|
||||
$serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php');
|
||||
|
||||
expect(substr_count($tokenFormView, 'wire:loading.attr="disabled" wire:target="addToken"'))->toBe(2)
|
||||
->and(substr_count($tokenFormView, '<x-loading-on-button wire:loading wire:target="addToken" />'))->toBe(2)
|
||||
->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"')
|
||||
->and($tokenShowView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />')
|
||||
->and($serverTokenView)->toContain('wire:loading.attr="disabled"')
|
||||
->and($serverTokenView)->toContain('wire:target="validateToken"')
|
||||
->and($serverTokenView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />');
|
||||
expect($tokenFormView)->toContain('wire:target="addToken"')
|
||||
->and($tokenFormView)->toContain('<x-forms.button type="submit"')
|
||||
->and($tokenShowView)->toContain('wire:click="validateToken"')
|
||||
->and($tokenShowView)->toContain('<x-forms.button type="button" wire:click="validateToken">')
|
||||
->and($serverTokenView)->toContain('wire:click.prevent="validateToken"')
|
||||
->and($serverTokenView)->toContain('<x-forms.button canGate="update" :canResource="$server"');
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
test('normalizeComposeServiceName replaces hyphens and dots', function () {
|
||||
expect(normalizeComposeServiceName('another-service'))->toBe('another_service')
|
||||
->and(normalizeComposeServiceName('api.test'))->toBe('api_test')
|
||||
->and(normalizeComposeServiceName('api'))->toBe('api');
|
||||
});
|
||||
|
||||
test('findComposeServiceName resolves legacy underscore keys to original names', function () {
|
||||
$services = ['another-service', 'api.test', 'api'];
|
||||
|
||||
expect(findComposeServiceName('another-service', $services))->toBe('another-service')
|
||||
->and(findComposeServiceName('another_service', $services))->toBe('another-service')
|
||||
->and(findComposeServiceName('api.test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('api_test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('missing', $services))->toBeNull();
|
||||
});
|
||||
|
||||
test('getComposeServiceDomainString reads original and legacy keys without dotted data_get bugs', function () {
|
||||
$domains = [
|
||||
'api.test' => ['domain' => 'https://dotted.example.com'],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
];
|
||||
|
||||
expect(getComposeServiceDomainString($domains, 'api.test'))->toBe('https://dotted.example.com')
|
||||
->and(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($domains, 'missing'))->toBeNull();
|
||||
});
|
||||
|
||||
test('putComposeServiceDomain writes original service keys and drops twin underscore keys', function () {
|
||||
$domains = putComposeServiceDomain(
|
||||
['another_service' => ['domain' => 'https://old.example.com']],
|
||||
'another-service',
|
||||
'https://new.example.com',
|
||||
['another-service', 'analytics'],
|
||||
);
|
||||
|
||||
expect($domains)->toHaveKey('another-service')
|
||||
->and($domains)->not->toHaveKey('another_service')
|
||||
->and($domains['another-service']['domain'])->toBe('https://new.example.com');
|
||||
});
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames migrates underscore keys to original compose names', function () {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames(
|
||||
[
|
||||
'another_service' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api_test' => ['domain' => 'https://dotted.example.com'],
|
||||
'orphan' => ['domain' => 'https://orphan.example.com'],
|
||||
],
|
||||
['another-service', 'api.test'],
|
||||
);
|
||||
|
||||
expect($rekeyed)->toHaveKey('another-service')
|
||||
->and($rekeyed)->not->toHaveKey('another_service')
|
||||
->and($rekeyed['another-service']['domain'])->toBe('https://hyphen.example.com')
|
||||
->and($rekeyed)->toHaveKey('api.test')
|
||||
->and($rekeyed['api.test']['domain'])->toBe('https://dotted.example.com')
|
||||
->and($rekeyed)->toHaveKey('orphan');
|
||||
});
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames prefers the canonical domain regardless of twin key order', function (array $domains) {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']);
|
||||
|
||||
expect($rekeyed['another-service']['domain'])->toBe('https://canonical.example.com');
|
||||
})->with([
|
||||
'legacy key first' => [[
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
'another-service' => ['domain' => 'https://canonical.example.com'],
|
||||
]],
|
||||
'canonical key first' => [[
|
||||
'another-service' => ['domain' => 'https://canonical.example.com'],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
]],
|
||||
]);
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames never lets empty canonical wipe filled legacy', function (array $domains) {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']);
|
||||
|
||||
expect($rekeyed)->toHaveKey('another-service')
|
||||
->and($rekeyed)->not->toHaveKey('another_service')
|
||||
->and($rekeyed['another-service']['domain'])->toBe('https://legacy.example.com');
|
||||
})->with([
|
||||
'empty canonical first' => [[
|
||||
'another-service' => ['domain' => ''],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
]],
|
||||
'filled legacy first' => [[
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
'another-service' => ['domain' => ''],
|
||||
]],
|
||||
]);
|
||||
|
||||
test('getComposeServiceDomainString prefers filled twin over blank original key', function () {
|
||||
$domains = [
|
||||
'another-service' => ['domain' => ''],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
];
|
||||
|
||||
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($domains, 'another_service'))->toBe('https://legacy.example.com');
|
||||
});
|
||||
|
||||
test('preferredComposeServiceNamesFromDomainKeys collapses underscore twins', function () {
|
||||
expect(preferredComposeServiceNamesFromDomainKeys(['web_api', 'web-api', 'api']))->toEqualCanonicalizing(['web-api', 'api']);
|
||||
});
|
||||
|
||||
test('legacy underscore domain keys still resolve for hyphenated compose services', function () {
|
||||
// Existing production shape: domains stored under underscore keys only.
|
||||
$legacy = [
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
'web' => ['domain' => 'https://web.example.com'],
|
||||
];
|
||||
|
||||
expect(getComposeServiceDomainString($legacy, 'another-service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($legacy, 'another_service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($legacy, 'web'))->toBe('https://web.example.com');
|
||||
});
|
||||
|
||||
test('SERVICE env keys stay underscore-normalized for both storage shapes', function () {
|
||||
// Mirrors ApplicationDeploymentJob: domain map key may be original or legacy.
|
||||
foreach (['another-service', 'another_service'] as $storageKey) {
|
||||
$envKey = str(normalizeComposeServiceName($storageKey))->upper()->toString();
|
||||
expect($envKey)->toBe('ANOTHER_SERVICE')
|
||||
->and('SERVICE_URL_'.$envKey)->toBe('SERVICE_URL_ANOTHER_SERVICE');
|
||||
}
|
||||
});
|
||||
|
||||
test('findComposeServiceName maps SERVICE env fragments used by serviceParser magic path', function () {
|
||||
// serviceParser historically only did underscore→hyphen, so API_TEST became name "api-test"
|
||||
// and missed compose service "api.test". normalize-based lookup finds the real name.
|
||||
$services = ['another-service', 'api.test', 'web'];
|
||||
|
||||
expect(findComposeServiceName('another_service', $services))->toBe('another-service')
|
||||
->and(findComposeServiceName('api_test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('api-test', $services))->toBe('api.test') // same normalized form
|
||||
->and(findComposeServiceName('web', $services))->toBe('web');
|
||||
|
||||
// Exact DB lookup the old serviceParser used would miss dotted names:
|
||||
expect(in_array('api-test', $services, true))->toBeFalse()
|
||||
->and(in_array('api.test', $services, true))->toBeTrue();
|
||||
});
|
||||
|
||||
test('normalized service name collisions only resolve exact matches', function () {
|
||||
$services = ['api-test', 'api.test'];
|
||||
|
||||
expect(findComposeServiceName('api-test', $services))->toBe('api-test')
|
||||
->and(findComposeServiceName('api.test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('api_test', $services))->toBeNull();
|
||||
});
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames keeps ambiguous normalized keys separate', function () {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames(
|
||||
[
|
||||
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||
'api_test' => ['domain' => 'https://legacy.example.com'],
|
||||
],
|
||||
['api-test', 'api.test'],
|
||||
);
|
||||
|
||||
expect($rekeyed['api-test']['domain'])->toBe('https://hyphen.example.com')
|
||||
->and($rekeyed['api.test']['domain'])->toBe('https://dot.example.com')
|
||||
->and($rekeyed['api_test']['domain'])->toBe('https://legacy.example.com');
|
||||
});
|
||||
|
||||
test('empty parsed compose services do not wipe existing domains', function () {
|
||||
$application = new Application;
|
||||
$application->docker_compose_domains = json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com'],
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod($application, 'reconcileDockerComposeDomains');
|
||||
$method->invoke($application, collect(['services' => []]));
|
||||
|
||||
expect($application->docker_compose_domains)->toBe(json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com'],
|
||||
]));
|
||||
});
|
||||
|
||||
test('normalized service collisions retain domains under their exact compose keys', function () {
|
||||
$domains = collect([
|
||||
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||
]);
|
||||
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames($domains, collect(['api-test', 'api.test']));
|
||||
|
||||
expect($rekeyed)->toBe([
|
||||
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('General rekeys form domains to original compose service names', function (array|Collection $domains) {
|
||||
$component = new General;
|
||||
$component->parsedServices = collect([
|
||||
'services' => collect([
|
||||
'web-api' => [],
|
||||
'metrics.internal' => [],
|
||||
]),
|
||||
]);
|
||||
$component->parsedServiceDomains = $domains;
|
||||
|
||||
$method = new ReflectionMethod($component, 'composeDomainsForStorage');
|
||||
$stored = $method->invoke($component);
|
||||
|
||||
expect($stored)->toBe([
|
||||
'web-api' => ['domain' => 'https://api.example.com'],
|
||||
'metrics.internal' => ['domain' => 'https://metrics.example.com'],
|
||||
]);
|
||||
})->with([
|
||||
'array' => [[
|
||||
'web_api' => ['domain' => 'https://api.example.com'],
|
||||
'metrics_internal' => ['domain' => 'https://metrics.example.com'],
|
||||
]],
|
||||
'collection' => [collect([
|
||||
'web_api' => ['domain' => 'https://api.example.com'],
|
||||
'metrics_internal' => ['domain' => 'https://metrics.example.com'],
|
||||
])],
|
||||
]);
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
it('returns zero for null or empty fqdn', function () {
|
||||
expect(countDomains(null))->toBe(0)
|
||||
->and(countDomains(''))->toBe(0)
|
||||
->and(countDomains(' '))->toBe(0);
|
||||
});
|
||||
|
||||
it('counts a single domain', function () {
|
||||
expect(countDomains('https://app.coolify.io'))->toBe(1)
|
||||
->and(countDomains(' https://app.coolify.io '))->toBe(1);
|
||||
});
|
||||
|
||||
it('counts multiple comma-separated domains', function () {
|
||||
expect(countDomains('https://app.coolify.io,https://cloud.coolify.io/dashboard'))->toBe(2)
|
||||
->and(countDomains('https://a.com, https://b.com , https://c.com'))->toBe(3);
|
||||
});
|
||||
|
||||
it('ignores empty segments between commas', function () {
|
||||
expect(countDomains('https://a.com,,https://b.com,'))->toBe(2)
|
||||
->and(countDomains(',,,'))->toBe(0);
|
||||
});
|
||||
@@ -4,6 +4,8 @@ use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
@@ -79,6 +81,35 @@ it('detects redeploy-only domain changes', function () {
|
||||
->and($change['new_full_value'])->toBe($domains);
|
||||
});
|
||||
|
||||
it('detects added storage mounts as redeploy-only changes', function (string $type) {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
if ($type === 'volume') {
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $application->uuid.'-data',
|
||||
'mount_path' => '/app/data',
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]);
|
||||
} else {
|
||||
LocalFileVolume::withoutEvents(fn () => LocalFileVolume::forceCreate([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/data',
|
||||
'mount_path' => '/app/data',
|
||||
'is_directory' => $type === 'directory',
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]));
|
||||
}
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->requiresBuild())->toBeFalse()
|
||||
->and(collect($diff->changes())->pluck('section'))->toContain('storage');
|
||||
})->with(['volume', 'directory', 'file']);
|
||||
|
||||
it('detects Docker image reference changes as redeploy-only changes', function (string $field, string $label, string $newValue) {
|
||||
$application = snapshotTestApplication([
|
||||
'build_pack' => 'dockerimage',
|
||||
|
||||
@@ -7,6 +7,7 @@ it('defines OpenAPI documentation for every destinations endpoint', function ()
|
||||
expect($model)->toContain("schema: 'Destination'")
|
||||
->and($controller)->toContain("operationId: 'list-destinations'")
|
||||
->and($controller)->toContain("operationId: 'get-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'update-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'delete-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'list-server-destinations'")
|
||||
->and($controller)->toContain("operationId: 'create-server-destination'");
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('maps final DigitalOcean API error responses', function (int $status, string $message) {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response(['message' => $message], $status),
|
||||
]);
|
||||
|
||||
$exception = null;
|
||||
|
||||
try {
|
||||
(new DigitalOceanService('test-token'))->createDroplet([]);
|
||||
} catch (Throwable $caught) {
|
||||
$exception = $caught;
|
||||
}
|
||||
|
||||
expect($exception)->toBeInstanceOf(Exception::class)
|
||||
->and($exception)->not->toBeInstanceOf(RequestException::class)
|
||||
->and($exception->getMessage())->toBe('DigitalOcean API error: '.$message)
|
||||
->and($exception->getCode())->toBe($status);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
})->with([
|
||||
'unauthorized' => [401, 'Unable to authenticate you'],
|
||||
'unprocessable entity' => [422, 'Droplet name is invalid'],
|
||||
]);
|
||||
|
||||
it('maps a final DigitalOcean rate-limit response with Retry-After', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::sequence()
|
||||
->push(['message' => 'Temporary provider error'], 500)
|
||||
->push(['message' => 'Temporary provider error'], 500)
|
||||
->push(['message' => 'Too many requests'], 429, ['Retry-After' => '45']),
|
||||
]);
|
||||
|
||||
try {
|
||||
(new DigitalOceanService('test-token'))->createDroplet([]);
|
||||
} catch (RateLimitException $exception) {
|
||||
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
|
||||
->and($exception->retryAfter)->toBe(45);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
test()->fail('Expected a RateLimitException to be thrown.');
|
||||
});
|
||||
|
||||
it('fetches paginated regions from DigitalOcean', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
it('returns A for ipv4 addresses', function () {
|
||||
expect(dnsRecordTypeForIp('203.0.113.10'))->toBe('A')
|
||||
->and(dnsRecordTypeForIp('172.16.0.3 (coolify-testing-host)'))->toBe('A');
|
||||
});
|
||||
|
||||
it('returns AAAA for ipv6 addresses', function () {
|
||||
expect(dnsRecordTypeForIp('2001:db8::1'))->toBe('AAAA')
|
||||
->and(dnsRecordTypeForIp('[2001:db8::1]'))->toBe('AAAA')
|
||||
->and(dnsRecordTypeForIp('2001:db8::1 (my-host)'))->toBe('AAAA');
|
||||
});
|
||||
|
||||
it('defaults to A when the address is missing or not an ip', function () {
|
||||
expect(dnsRecordTypeForIp(null))->toBe('A')
|
||||
->and(dnsRecordTypeForIp(''))->toBe('A')
|
||||
->and(dnsRecordTypeForIp('coolify-testing-host'))->toBe('A');
|
||||
});
|
||||
|
||||
it('builds a short A-record guidance message for ipv4 targets', function () {
|
||||
$message = dnsMismatchGuidanceMessage('172.16.0.3 (coolify-testing-host)', '172.16.0.3');
|
||||
|
||||
expect($message)->toBe('Required DNS record type A pointing to 172.16.0.3')
|
||||
->and($message)->not->toContain('—')
|
||||
->and($message)->not->toContain('continue');
|
||||
});
|
||||
|
||||
it('builds a short AAAA-record guidance message for ipv6 targets', function () {
|
||||
$message = dnsMismatchGuidanceMessage('2001:db8::1', '2001:db8::1');
|
||||
|
||||
expect($message)->toBe('Required DNS record type AAAA pointing to 2001:db8::1')
|
||||
->and($message)->not->toContain('—')
|
||||
->and($message)->not->toContain('continue');
|
||||
});
|
||||
|
||||
it('prefers the bare ip over a hostname label', function () {
|
||||
expect(dnsMismatchGuidanceMessage('coolify-testing-host', '172.16.0.3'))
|
||||
->toBe('Required DNS record type A pointing to 172.16.0.3');
|
||||
});
|
||||
|
||||
it('falls back when no target is available', function () {
|
||||
expect(dnsMismatchGuidanceMessage(null))->toBe('DNS validation failed. Check your DNS records.');
|
||||
});
|
||||
|
||||
it('accepts Cloudflare ipv6 proxy addresses', function () {
|
||||
expect(isCloudflareIp('2606:4700:3037::6815:4f2b'))->toBeTrue()
|
||||
->and(isCloudflareIp('2001:db8::1'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared domain URL helpers used by application/service parsers and updateCompose.
|
||||
*
|
||||
* TDD targets:
|
||||
* - getHostWithoutPort(): COOLIFY_FQDN / SERVICE_FQDN host-only form (no scheme, no port)
|
||||
* - firstDomainFromList(): comma-separated FQDN lists (same as updateCompose / serviceParser)
|
||||
* - getFqdnWithoutPort() remains the scheme+host form for COOLIFY_URL / SERVICE_URL
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getHostWithoutPort — expected behavior (fails until helper exists / is correct)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('getHostWithoutPort strips scheme and port from simple urls', function () {
|
||||
expect(getHostWithoutPort('http://git.example.com:80'))->toBe('git.example.com')
|
||||
->and(getHostWithoutPort('https://n8n.example.com:5678'))->toBe('n8n.example.com')
|
||||
->and(getHostWithoutPort('http://git.example.com'))->toBe('git.example.com');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort preserves path segments', function () {
|
||||
expect(getHostWithoutPort('http://git.example.com:80/v1/realtime'))
|
||||
->toBe('git.example.com/v1/realtime');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort keeps real host when credentials are present', function () {
|
||||
// Legacy COOLIFY_FQDN strip: replace scheme then before(':') → "user"
|
||||
expect(getHostWithoutPort('http://user:secret@git.example.com:80'))
|
||||
->toBe('git.example.com');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort handles scheme-less host:port', function () {
|
||||
expect(getHostWithoutPort('git.example.com:80'))->toBe('git.example.com');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort returns original when host cannot be parsed', function () {
|
||||
expect(getHostWithoutPort('not-a-url'))->toBe('not-a-url');
|
||||
});
|
||||
|
||||
test('legacy COOLIFY_FQDN strip corrupts credential urls (documents why we refactor)', function () {
|
||||
$fqdn = 'http://user:secret@git.example.com:80';
|
||||
|
||||
$legacy = (string) str($fqdn)
|
||||
->replace('http://', '')
|
||||
->replace('https://', '')
|
||||
->before(':');
|
||||
|
||||
expect($legacy)->toBe('user')
|
||||
->and($legacy)->not->toBe(getHostWithoutPort($fqdn));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// firstDomainFromList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('firstDomainFromList returns the first comma-separated domain trimmed', function () {
|
||||
expect(firstDomainFromList('http://a.example.com:80,http://b.example.com:80'))
|
||||
->toBe('http://a.example.com:80')
|
||||
->and(firstDomainFromList(' https://only.example.com '))
|
||||
->toBe('https://only.example.com')
|
||||
->and(firstDomainFromList(null))
|
||||
->toBe('')
|
||||
->and(firstDomainFromList(''))
|
||||
->toBe('');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateCompose / parser pairing: URL + FQDN from the same helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('url and host helpers stay paired for ported service domains', function () {
|
||||
$fqdn = 'http://git.example.com:80';
|
||||
|
||||
$urlValue = getFqdnWithoutPort($fqdn);
|
||||
$fqdnValue = getHostWithoutPort($fqdn);
|
||||
$port = '80';
|
||||
|
||||
expect($urlValue)->toBe('http://git.example.com')
|
||||
->and($fqdnValue)->toBe('git.example.com')
|
||||
->and($urlValue.':'.$port)->toBe('http://git.example.com:80')
|
||||
->and($fqdnValue.':'.$port)->toBe('git.example.com:80')
|
||||
->and($urlValue.':'.$port)->not->toContain('/:');
|
||||
});
|
||||
|
||||
test('first domain then helpers match multi-domain service FQDN handling', function () {
|
||||
$saved = 'http://git.example.com:80,http://git-alt.example.com:80';
|
||||
$first = firstDomainFromList($saved);
|
||||
|
||||
expect($first)->toBe('http://git.example.com:80')
|
||||
->and(getFqdnWithoutPort($first))->toBe('http://git.example.com')
|
||||
->and(getHostWithoutPort($first))->toBe('git.example.com');
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
<?php
|
||||
|
||||
it('normalizes plus and dotted email identity variants', function () {
|
||||
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
expect(normalize_email_identity('Ke.VinMcFadden+one@Gmail.com'))->toBe('kevinmcfadden@gmail.com');
|
||||
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@googlemail.com'))->toBe('kevinmcfadden@googlemail.com');
|
||||
});
|
||||
|
||||
it('preserves dots and plus suffixes for ordinary email providers', function () {
|
||||
expect(normalize_email_identity(' John.Smith+alerts@Example.com '))->toBe('john.smith+alerts@example.com');
|
||||
});
|
||||
|
||||
it('returns null for blank or malformed email identities', function (?string $email) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Url\Url;
|
||||
|
||||
/**
|
||||
* Regression tests for service domain FQDN port handling (#8798 / #8980).
|
||||
*
|
||||
* Fragile str()->after('://')->before(':') stripping corrupts URLs that contain
|
||||
* credentials, IPv6, or other non-trivial structure. getFqdnWithoutPort() must
|
||||
* strip only the port and keep a usable base URL for COOLIFY_URL / SERVICE_URL.
|
||||
*/
|
||||
test('getFqdnWithoutPort strips port from simple http urls without trailing slash', function () {
|
||||
expect(getFqdnWithoutPort('http://git.example.com:80'))->toBe('http://git.example.com')
|
||||
->and(getFqdnWithoutPort('https://n8n.example.com:5678'))->toBe('https://n8n.example.com')
|
||||
->and(getFqdnWithoutPort('http://git.example.com'))->toBe('http://git.example.com');
|
||||
});
|
||||
|
||||
test('getFqdnWithoutPort preserves path segments when stripping port', function () {
|
||||
expect(getFqdnWithoutPort('http://git.example.com:80/v1/realtime'))
|
||||
->toBe('http://git.example.com/v1/realtime');
|
||||
});
|
||||
|
||||
test('getFqdnWithoutPort keeps host when credentials are present', function () {
|
||||
// Fragile strip would turn this into "http://user" — helper must not.
|
||||
expect(getFqdnWithoutPort('http://user:secret@git.example.com:80'))
|
||||
->toBe('http://git.example.com');
|
||||
});
|
||||
|
||||
test('getFqdnWithoutPort returns original string when input is not a valid url', function () {
|
||||
expect(getFqdnWithoutPort('not-a-url'))->toBe('not-a-url');
|
||||
});
|
||||
|
||||
test('fragile after/before strip corrupts credential urls (documents #8798 class of bugs)', function () {
|
||||
$fqdn = 'http://user:secret@git.example.com:80';
|
||||
|
||||
$fragile = (string) str($fqdn)
|
||||
->after('://')
|
||||
->before(':')
|
||||
->prepend(str($fqdn)->before('://')->append('://'));
|
||||
|
||||
expect($fragile)->toBe('http://user')
|
||||
->and($fragile)->not->toBe(getFqdnWithoutPort($fqdn));
|
||||
});
|
||||
|
||||
test('port comparisons must cast so string template ports match int url ports', function () {
|
||||
$envPort = Url::fromString('http://git.example.com:80')->getPort();
|
||||
$predefinedPort = '80'; // yaml / data_get style
|
||||
|
||||
expect($envPort !== $predefinedPort)->toBeTrue('strict compare wrongly treats matching ports as different')
|
||||
->and((int) $envPort !== (int) $predefinedPort)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
});
|
||||
|
||||
test('connected gitlab oauth submodule credentials use per command git config', function () {
|
||||
$application = new Application;
|
||||
$application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_repository' => 'group/private-app',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$application->setRelation('settings', $settings);
|
||||
|
||||
$source = new GitlabApp;
|
||||
$source->forceFill([
|
||||
'html_url' => 'https://gitlab.example.test',
|
||||
'api_url' => 'https://gitlab.example.test/api/v4',
|
||||
'is_public' => false,
|
||||
]);
|
||||
// A non-expired token short-circuits refreshGitlabToken(), so no HTTP call is made.
|
||||
$source->access_token = 'gl-token/with+sym';
|
||||
$source->refresh_token = 'gl-refresh-token';
|
||||
$source->expires_at = time() + 3600;
|
||||
$application->setRelation('source', $source);
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: 'test-deployment',
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
$expectedConfig = "git -c 'url.https://oauth2:gl-token%2Fwith%2Bsym@gitlab.example.test/.insteadOf=https://gitlab.example.test/' -c http.version=HTTP/1.1";
|
||||
|
||||
expect($result['commands'])
|
||||
->not->toContain('git config --global')
|
||||
->toContain("{$expectedConfig} clone --recurse-submodules -b 'main'")
|
||||
->toContain("{$expectedConfig} submodule sync")
|
||||
->toContain("{$expectedConfig} submodule update --init --recursive");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
});
|
||||
|
||||
it('returns api base url with /api/v4 appended when missing', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('returns api base url unchanged when /api/v4 already present', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('strips trailing slash from api url base', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4/']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('reports connected when tokens are present', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => str_repeat('r', 20), // ggignore
|
||||
]);
|
||||
expect($app->isConnected())->toBeTrue();
|
||||
});
|
||||
|
||||
it('reports not connected when tokens are missing', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => null,
|
||||
'refresh_token' => null,
|
||||
]);
|
||||
expect($app->isConnected())->toBeFalse();
|
||||
});
|
||||
|
||||
it('reports not connected when only access token is present', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => null,
|
||||
]);
|
||||
expect($app->isConnected())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
it('redacts oauth2 tokens from deployment logs', function () {
|
||||
$fakeOAuthToken = str_repeat('a', 30); // ggignore
|
||||
$text = "git clone https://oauth2:{$fakeOAuthToken}@gitlab.example.com/group/repo.git /app";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeOAuthToken);
|
||||
expect($result)->toContain('oauth2:');
|
||||
});
|
||||
|
||||
it('redacts x-access-token from logs', function () {
|
||||
$fakeToken = str_repeat('x', 40); // ggignore
|
||||
$text = "git clone https://x-access-token:{$fakeToken}@github.com/org/repo.git /app";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeToken);
|
||||
expect($result)->toContain('x-access-token:');
|
||||
});
|
||||
|
||||
it('redacts gitlab personal access tokens', function () {
|
||||
$fakeToken = 'glpat-'.str_repeat('y', 20); // ggignore
|
||||
$text = "Authorization: Bearer {$fakeToken}";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeToken);
|
||||
});
|
||||
@@ -3,9 +3,16 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
@@ -54,6 +61,9 @@ it('generates ls-remote commands for GitLab source with private key', function (
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -83,6 +93,9 @@ it('generates ls-remote commands for GitLab source without private key', functio
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -112,6 +125,9 @@ it('does not return null for GitLab source type', function () {
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -127,3 +143,73 @@ it('does not return null for GitLab source type', function () {
|
||||
expect($lsRemoteResult)->not->toBeNull();
|
||||
expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
|
||||
});
|
||||
|
||||
it('preserves custom GitLab http port for connected OAuth sources', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = new GitlabApp([
|
||||
'html_url' => 'http://gitlab.example.test:8081',
|
||||
'access_token' => 'gitlab-access-token',
|
||||
'refresh_token' => 'gitlab-refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'root/qa-private-app',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||
|
||||
expect($result['fullRepoUrl'])
|
||||
->toContain('gitlab.example.test:8081')
|
||||
->toBe('http://oauth2:gitlab-access-token@gitlab.example.test:8081/root/qa-private-app.git');
|
||||
expect($result['commands'])->toContain('gitlab.example.test:8081/root/qa-private-app.git');
|
||||
});
|
||||
|
||||
it('applies OAuth git config to GitLab merge-request fetch and submodule checkout', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = new GitlabApp([
|
||||
'html_url' => 'https://gitlab.example.test',
|
||||
'access_token' => 'gitlab-access-token',
|
||||
'refresh_token' => 'gitlab-refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
$settings = (object) [
|
||||
'is_git_shallow_clone_enabled' => false,
|
||||
'is_git_submodules_enabled' => true,
|
||||
];
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'root/qa-private-app',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: $deploymentUuid,
|
||||
pull_request_id: 2,
|
||||
exec_in_docker: false,
|
||||
only_checkout: true,
|
||||
custom_base_dir: '/artifacts/test',
|
||||
);
|
||||
|
||||
$commands = $result['commands'];
|
||||
// The MR-ref fetch and submodule update must run through the OAuth-rewritten git, or private same-host submodules fail.
|
||||
expect($commands)
|
||||
->toContain('oauth2:gitlab-access-token@gitlab.example.test')
|
||||
->toContain("http.version=HTTP/1.1 fetch origin 'merge-requests/2/head:pr-2-coolify'")
|
||||
->toContain('http.version=HTTP/1.1 submodule update --init --recursive');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
it('uses OS-aware modifier key labels in the global search palette', function () {
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain('modKeyLabel')
|
||||
->toContain("return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X|Macintosh/i.test(ua) ? '⌘' : 'Ctrl+';")
|
||||
->toContain("x-text=\"modKeyLabel + 'K'\"")
|
||||
->not->toContain('>⌘K</span>');
|
||||
});
|
||||
|
||||
it('uses OS-aware modifier key labels in the sidebar search trigger', function () {
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/components/navbar.blade.php');
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain('modKeyLabel')
|
||||
->toContain("return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X|Macintosh/i.test(ua) ? '⌘' : 'Ctrl+';")
|
||||
->toContain(":title=\"'Search (Press / or ' + modKeyLabel + 'K)'\"")
|
||||
->toContain("x-text=\"modKeyLabel + 'K'\"")
|
||||
->not->toContain('>⌘K</kbd>')
|
||||
->not->toContain('title="Search (Press / or ⌘K)"');
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
it('loads the database helper without PHP warnings', function () {
|
||||
$process = new Process([
|
||||
PHP_BINARY,
|
||||
'-d',
|
||||
'display_errors=1',
|
||||
'-d',
|
||||
'error_reporting=E_ALL',
|
||||
'-l',
|
||||
__DIR__.'/../../bootstrap/helpers/databases.php',
|
||||
]);
|
||||
|
||||
$process->run();
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue()
|
||||
->and($process->getOutput().$process->getErrorOutput())->not->toContain('Warning');
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
test('deployment log timestamps omit microseconds', function () {
|
||||
$helper = file_get_contents(dirname(__DIR__, 2).'/bootstrap/helpers/remoteProcess.php');
|
||||
|
||||
expect($helper)
|
||||
->toContain("->format('Y-M-d H:i:s')")
|
||||
->not->toContain("->format('Y-M-d H:i:s.u')");
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
|
||||
/**
|
||||
* Expose protected action helpers for unit testing without booting MCP tools.
|
||||
*/
|
||||
class BuildsResponseActionsHarness
|
||||
{
|
||||
use BuildsResponse;
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function applicationActions(string $uuid, ?string $status = null): array
|
||||
{
|
||||
return $this->actionsForApplication($uuid, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function databaseActions(string $uuid, ?string $status = null): array
|
||||
{
|
||||
return $this->actionsForDatabase($uuid, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function serviceActions(string $uuid, ?string $status = null): array
|
||||
{
|
||||
return $this->actionsForService($uuid, $status);
|
||||
}
|
||||
}
|
||||
|
||||
function controlActionFrom(array $actions): ?string
|
||||
{
|
||||
$control = collect($actions)->firstWhere('tool', 'control');
|
||||
|
||||
return is_array($control) ? ($control['args']['action'] ?? null) : null;
|
||||
}
|
||||
|
||||
function toolsFrom(array $actions): array
|
||||
{
|
||||
return collect($actions)->pluck('tool')->all();
|
||||
}
|
||||
|
||||
test('healthy running application suggests logs restart and stop', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->applicationActions('app-uuid', 'running:healthy');
|
||||
|
||||
expect(toolsFrom($actions))->toContain('get_logs')
|
||||
->and(collect($actions)->pluck('args.action')->filter()->all())->toContain('restart', 'stop')
|
||||
->and(toolsFrom($actions))->not->toContain('deploy');
|
||||
});
|
||||
|
||||
test('unhealthy running application suggests logs and restart not start', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->applicationActions('app-uuid', 'running:unhealthy');
|
||||
|
||||
expect(toolsFrom($actions))->toContain('get_logs')
|
||||
->and(controlActionFrom($actions))->toBe('restart')
|
||||
->and(collect($actions)->pluck('args.action')->filter()->all())->not->toContain('start')
|
||||
->and(toolsFrom($actions))->not->toContain('deploy');
|
||||
});
|
||||
|
||||
test('stopped application suggests deploy and start not restart', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->applicationActions('app-uuid', 'exited');
|
||||
|
||||
expect(toolsFrom($actions))->toContain('deploy')
|
||||
->and(controlActionFrom($actions))->toBe('start')
|
||||
->and(toolsFrom($actions))->not->toContain('get_logs');
|
||||
});
|
||||
|
||||
test('healthy running database suggests logs and restart', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->databaseActions('db-uuid', 'running:healthy');
|
||||
|
||||
expect(toolsFrom($actions))->toContain('get_logs')
|
||||
->and(controlActionFrom($actions))->toBe('restart');
|
||||
});
|
||||
|
||||
test('unhealthy running database suggests logs and restart not start', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->databaseActions('db-uuid', 'running:unhealthy');
|
||||
|
||||
expect(toolsFrom($actions))->toContain('get_logs')
|
||||
->and(controlActionFrom($actions))->toBe('restart')
|
||||
->and(collect($actions)->pluck('args.action')->filter()->all())->not->toContain('start');
|
||||
});
|
||||
|
||||
test('stopped database suggests start not restart', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->databaseActions('db-uuid', 'exited');
|
||||
|
||||
expect(controlActionFrom($actions))->toBe('start')
|
||||
->and(toolsFrom($actions))->not->toContain('get_logs');
|
||||
});
|
||||
|
||||
test('healthy running service suggests logs and restart', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->serviceActions('svc-uuid', 'running:healthy');
|
||||
|
||||
expect(toolsFrom($actions))->toContain('get_logs')
|
||||
->and(controlActionFrom($actions))->toBe('restart');
|
||||
});
|
||||
|
||||
test('unhealthy running service suggests logs and restart not start', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->serviceActions('svc-uuid', 'running:unhealthy');
|
||||
|
||||
expect(toolsFrom($actions))->toContain('get_logs')
|
||||
->and(controlActionFrom($actions))->toBe('restart')
|
||||
->and(collect($actions)->pluck('args.action')->filter()->all())->not->toContain('start');
|
||||
});
|
||||
|
||||
test('stopped service suggests start not restart', function () {
|
||||
$actions = (new BuildsResponseActionsHarness)->serviceActions('svc-uuid', 'exited:unhealthy');
|
||||
|
||||
expect(controlActionFrom($actions))->toBe('start')
|
||||
->and(toolsFrom($actions))->not->toContain('get_logs');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
it('uses at least sixteen pixel form controls on mobile to prevent input focus zoom', function (string $stylesheet) {
|
||||
$css = file_get_contents(dirname(__DIR__, 2).'/'.$stylesheet);
|
||||
$baseLayerStart = strpos($css, '@layer base {');
|
||||
$mobileRuleStart = strpos($css, '@media (max-width: 767px)');
|
||||
|
||||
expect($baseLayerStart)->not->toBeFalse()
|
||||
->and($mobileRuleStart)->toBeGreaterThan($baseLayerStart)
|
||||
->and(substr_count(substr($css, $baseLayerStart, $mobileRuleStart - $baseLayerStart), '{'))
|
||||
->toBeGreaterThan(substr_count(substr($css, $baseLayerStart, $mobileRuleStart - $baseLayerStart), '}'))
|
||||
->and($css)->toContain(':root input,')
|
||||
->and($css)->toContain(':root textarea,')
|
||||
->and($css)->toContain(':root select')
|
||||
->and($css)->toContain('font-size: 16px !important;');
|
||||
})->with([
|
||||
'current interface' => 'resources/css/app.css',
|
||||
'v5 interface' => 'resources/css/v5/app.css',
|
||||
]);
|
||||
|
||||
it('keeps user zoom enabled in the viewport meta tags', function () {
|
||||
$layouts = [
|
||||
'resources/views/layouts/base.blade.php',
|
||||
'resources/views/v5/app.blade.php',
|
||||
];
|
||||
|
||||
foreach ($layouts as $layout) {
|
||||
expect(file_get_contents(dirname(__DIR__, 2).'/'.$layout))
|
||||
->not->toContain('maximum-scale')
|
||||
->not->toContain('user-scalable=no');
|
||||
}
|
||||
});
|
||||
@@ -144,6 +144,26 @@ it('denies team member to delete non-system-wide github app', function () {
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies update when team_id is null without type error', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldNotReceive('isAdminOfTeam');
|
||||
|
||||
$model = mockGithubApp(teamId: null, isSystemWide: false);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies delete when team_id is null without type error', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldNotReceive('isAdminOfTeam');
|
||||
|
||||
$model = mockGithubApp(teamId: null, isSystemWide: false);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
@@ -162,7 +182,7 @@ it('denies force delete of github app', function () {
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
function mockGithubApp(int $teamId, bool $isSystemWide): GithubApp
|
||||
function mockGithubApp(?int $teamId, bool $isSystemWide): GithubApp
|
||||
{
|
||||
$githubApp = Mockery::mock(GithubApp::class)->makePartial();
|
||||
$githubApp->team_id = $teamId;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\User;
|
||||
use App\Policies\GitlabAppPolicy;
|
||||
|
||||
it('allows any user to view any gitlab apps', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows any user to view system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows team member to view non-system-wide gitlab app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-team member to view non-system-wide gitlab app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to update system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to update system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update non-system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update non-system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to delete system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to delete system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to delete non-system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete non-system-wide gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies update when team_id is null without type error', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldNotReceive('isAdminOfTeam');
|
||||
|
||||
$model = mockGitlabApp(teamId: null, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies delete when team_id is null without type error', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldNotReceive('isAdminOfTeam');
|
||||
|
||||
$model = mockGitlabApp(teamId: null, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->restore($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies force delete of gitlab app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
|
||||
|
||||
$policy = new GitlabAppPolicy;
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
function mockGitlabApp(?int $teamId, bool $isSystemWide): GitlabApp
|
||||
{
|
||||
$gitlabApp = Mockery::mock(GitlabApp::class)->makePartial();
|
||||
$gitlabApp->team_id = $teamId;
|
||||
$gitlabApp->is_system_wide = $isSystemWide;
|
||||
|
||||
return $gitlabApp;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
it('publishes v4 branch builds only under the commit sha', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-sha-build.yml');
|
||||
|
||||
expect($workflow)
|
||||
->toContain('name: Build Coolify (SHA)')
|
||||
->toContain('sha-${{ github.sha }}-${{ matrix.arch }}')
|
||||
->toContain('sha-${{ github.sha }}')
|
||||
->not->toContain('bootstrap/getVersion.php')
|
||||
->not->toContain('steps.version.outputs.VERSION')
|
||||
->not->toContain('IMAGE_NAME }}:latest');
|
||||
});
|
||||
|
||||
it('promotes the released commit image without rebuilding it', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
|
||||
|
||||
expect($workflow)
|
||||
->toContain('release:')
|
||||
->toContain('types: [published]')
|
||||
->toContain('TAG_NAME: ${{ github.event.release.tag_name }}')
|
||||
->toContain('git rev-list -n 1 "${TAG_NAME}"')
|
||||
->toContain('SOURCE_TAG="sha-${RELEASE_SHA}"')
|
||||
->toContain('bootstrap/getVersion.php')
|
||||
->toContain('--tag "${IMAGE}:${VERSION}"')
|
||||
->not->toContain('docker/build-push-action');
|
||||
});
|
||||
|
||||
it('only promotes stable releases to latest', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
|
||||
|
||||
expect($workflow)
|
||||
->toContain('if: ${{ ! github.event.release.prerelease }}')
|
||||
->toContain('--tag "${IMAGE}:latest"');
|
||||
});
|
||||
|
||||
it('documents the sha image release process', function () {
|
||||
$releaseGuide = file_get_contents(dirname(__DIR__, 2).'/RELEASE.md');
|
||||
|
||||
expect($releaseGuide)
|
||||
->toContain('## Branch Strategy')
|
||||
->toContain('Fixes and release-ready patches')
|
||||
->toContain('open PRs against **`v4.x`**')
|
||||
->toContain('open PRs against **`next`**')
|
||||
->toContain('merge `v4.x` back into `next`')
|
||||
->toContain('Merge the release commit into `v4.x`')
|
||||
->toContain('`Build Coolify (SHA)`')
|
||||
->toContain('`sha-<commit-sha>`')
|
||||
->toContain('targeting the exact commit that produced the SHA image')
|
||||
->toContain('promotes the existing SHA image without rebuilding it')
|
||||
->toContain('Update the CDN')
|
||||
->toContain('Only commits on **`v4.x`** produce production SHA images')
|
||||
->not->toContain('Merging to `main`')
|
||||
->not->toContain('Production Build (v4)');
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('returns a literal ipv4 address unchanged', function () {
|
||||
$result = resolveServerIpAddress('203.0.113.10');
|
||||
|
||||
expect($result['ip'])->toBe('203.0.113.10')
|
||||
->and($result['configured'])->toBe('203.0.113.10')
|
||||
->and($result['resolved_from_hostname'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns a literal ipv6 address unchanged', function () {
|
||||
$result = resolveServerIpAddress('2001:db8::1');
|
||||
|
||||
expect($result['ip'])->toBe('2001:db8::1')
|
||||
->and($result['configured'])->toBe('2001:db8::1')
|
||||
->and($result['resolved_from_hostname'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('strips brackets from ipv6 addresses', function () {
|
||||
$result = resolveServerIpAddress('[2001:db8::1]');
|
||||
|
||||
expect($result['ip'])->toBe('2001:db8::1')
|
||||
->and($result['resolved_from_hostname'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('resolves localhost hostname to a real ip address', function () {
|
||||
$result = resolveServerIpAddress('localhost');
|
||||
|
||||
expect($result['configured'])->toBe('localhost')
|
||||
->and($result['resolved_from_hostname'])->toBeTrue()
|
||||
->and($result['ip'])->not->toBeNull()
|
||||
->and(filter_var($result['ip'], FILTER_VALIDATE_IP))->not->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns nulls for empty input', function () {
|
||||
expect(resolveServerIpAddress(null))->toMatchArray([
|
||||
'ip' => null,
|
||||
'configured' => null,
|
||||
'resolved_from_hostname' => false,
|
||||
])->and(resolveServerIpAddress(''))->toMatchArray([
|
||||
'ip' => null,
|
||||
'configured' => null,
|
||||
'resolved_from_hostname' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps configured hostname when it cannot be resolved', function () {
|
||||
$host = 'this-hostname-should-not-resolve-'.bin2hex(random_bytes(8)).'.invalid';
|
||||
$result = resolveServerIpAddress($host);
|
||||
|
||||
expect($result['configured'])->toBe($host)
|
||||
->and($result['ip'])->toBeNull()
|
||||
->and($result['resolved_from_hostname'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('caches successful hostname resolutions for subsequent lookups', function () {
|
||||
Cache::flush();
|
||||
|
||||
$result = resolveServerIpAddress('localhost');
|
||||
|
||||
expect($result['ip'])->not->toBeNull()
|
||||
->and(Cache::get('server-ip-resolve:localhost'))->toBe($result['ip']);
|
||||
|
||||
// A fresh cache hit must be preferred over re-querying DNS.
|
||||
Cache::put('server-ip-resolve:localhost', '203.0.113.99', 60);
|
||||
|
||||
$cached = resolveServerIpAddress('localhost');
|
||||
|
||||
expect($cached['ip'])->toBe('203.0.113.99')
|
||||
->and($cached['configured'])->toBe('localhost')
|
||||
->and($cached['resolved_from_hostname'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not cache failed hostname resolutions', function () {
|
||||
Cache::flush();
|
||||
|
||||
$host = 'this-hostname-should-not-resolve-'.bin2hex(random_bytes(8)).'.invalid';
|
||||
resolveServerIpAddress($host);
|
||||
|
||||
expect(Cache::get('server-ip-resolve:'.mb_strtolower($host)))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not use the cache for literal ip addresses', function () {
|
||||
Cache::put('server-ip-resolve:203.0.113.10', '198.51.100.1', 60);
|
||||
|
||||
$result = resolveServerIpAddress('203.0.113.10');
|
||||
|
||||
expect($result['ip'])->toBe('203.0.113.10')
|
||||
->and($result['resolved_from_hostname'])->toBeFalse();
|
||||
});
|
||||
@@ -104,3 +104,68 @@ it('accepts explicitly allowlisted intranet S3 endpoints', function (string $end
|
||||
'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
|
||||
'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
|
||||
]);
|
||||
|
||||
it('accepts allowlisted docker-style MinIO hostnames that resolve to private IPs', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => ['coolify-minio'],
|
||||
]));
|
||||
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['172.16.0.5'])]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected coolify-minio with private IP to pass when allowlisted');
|
||||
});
|
||||
|
||||
it('rejects docker-style MinIO hostnames resolving to private IPs without allowlist', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => [],
|
||||
]));
|
||||
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['172.16.0.5'])]],
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected coolify-minio private IP rejection without allowlist');
|
||||
});
|
||||
|
||||
it('allows the bundled MinIO endpoint only when explicitly trusted by S3 storage', function () {
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', new SafeWebhookUrl(
|
||||
fn (string $host): array => ['172.16.0.5'],
|
||||
trustedInternalHosts: ['coolify-minio'],
|
||||
)]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts allowlisted docker MinIO hostname after custom DNS miss and system DNS hit', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
'webhook_allowed_internal_hosts' => ['coolify-minio'],
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
return ['172.16.0.5'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', 'max:255', $rule]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected S3 endpoint to pass after DNS fallback + allowlist');
|
||||
});
|
||||
@@ -219,10 +219,32 @@ it('builds HTTP client options that pin resolved DNS for the request', function
|
||||
expect($options['allow_redirects'])->toBeFalse();
|
||||
|
||||
if (defined('CURLOPT_RESOLVE')) {
|
||||
expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1');
|
||||
expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1,[::1]');
|
||||
}
|
||||
});
|
||||
|
||||
it('pins every resolved address in a single CURLOPT_RESOLVE entry', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => ['localhost'],
|
||||
'webhook_allow_localhost' => true,
|
||||
]));
|
||||
|
||||
if (! defined('CURLOPT_RESOLVE')) {
|
||||
expect(true)->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook')['curl'][CURLOPT_RESOLVE];
|
||||
|
||||
// localhost resolves to both 127.0.0.1 and ::1. libcurl overrides an
|
||||
// existing host:port cache entry with each new one, so multiple entries
|
||||
// would leave only [::1] pinned and break every request on hosts without
|
||||
// IPv6 egress.
|
||||
expect($entries)->toHaveCount(1)
|
||||
->and($entries[0])->toBe('localhost:8080:127.0.0.1,[::1]');
|
||||
});
|
||||
|
||||
it('fails closed while building HTTP options when the send-time resolution is unsafe', function () {
|
||||
expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'))
|
||||
->toThrow(RuntimeException::class, 'unsafe IP address');
|
||||
@@ -239,6 +261,19 @@ it('builds MinIO client resolve options for S3 backup uploads', function () {
|
||||
expect($options)->toContain('localhost:9000=127.0.0.1');
|
||||
});
|
||||
|
||||
it('collapses dual-stack S3 hosts to a single bracket-free IPv4 resolve entry', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => ['localhost'],
|
||||
'webhook_allow_localhost' => true,
|
||||
]));
|
||||
|
||||
// localhost resolves to both 127.0.0.1 and ::1. The MinIO client keeps one IP per
|
||||
// host and rejects bracketed IPv6, so exactly one bracket-free IPv4 entry is produced.
|
||||
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
|
||||
|
||||
expect($options)->toBe(['localhost:9000=127.0.0.1']);
|
||||
});
|
||||
|
||||
it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
||||
|
||||
@@ -278,3 +313,103 @@ it('redacts webhook URLs for logs', function () {
|
||||
expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
|
||||
->toBe('https://hooks.slack.com');
|
||||
});
|
||||
|
||||
it('falls back to system DNS when custom DNS returns no answers', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
'webhook_allowed_internal_hosts' => ['coolify-minio'],
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
/** @var array<int, array{0: string, 1: array<int, string>}> */
|
||||
public array $customCalls = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $systemCalls = [];
|
||||
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
$this->customCalls[] = [$host, $dnsServers];
|
||||
|
||||
// Simulate public DNS (1.1.1.1) having no record for a docker hostname.
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
$this->systemCalls[] = $host;
|
||||
|
||||
return ['172.16.0.5'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['url' => 'http://coolify-minio:9000'],
|
||||
['url' => $rule],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected system-DNS fallback for allowlisted docker hostname')
|
||||
->and($rule->customCalls)->toHaveCount(1)
|
||||
->and($rule->customCalls[0][0])->toBe('coolify-minio')
|
||||
->and($rule->customCalls[0][1])->toBe(['1.1.1.1'])
|
||||
->and($rule->systemCalls)->toBe(['coolify-minio']);
|
||||
});
|
||||
|
||||
it('does not fall back to system DNS when custom DNS returns answers', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
public int $systemCalls = 0;
|
||||
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
return ['93.184.216.34'];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
$this->systemCalls++;
|
||||
|
||||
return ['10.0.0.1'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['url' => 'https://example.com/webhook'],
|
||||
['url' => $rule],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue()
|
||||
->and($rule->systemCalls)->toBe(0);
|
||||
});
|
||||
|
||||
it('still rejects private targets after system DNS fallback when host is not allowlisted', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
'webhook_allowed_internal_hosts' => [],
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
return ['172.16.0.5'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['url' => 'http://coolify-minio:9000'],
|
||||
['url' => $rule],
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected private IP rejection without allowlist after fallback');
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
test('wrap adds schema version export id and timestamp', function () {
|
||||
$bundle = ServerTransferBundle::wrap(['server' => ['uuid' => 'abc']]);
|
||||
|
||||
expect($bundle['schema_version'])->toBe(ServerTransferBundle::SCHEMA_VERSION)
|
||||
->and($bundle['export_id'])->toBeString()->not->toBeEmpty()
|
||||
->and($bundle['exported_at'])->toBeString()
|
||||
->and($bundle['server']['uuid'])->toBe('abc');
|
||||
});
|
||||
|
||||
test('validate rejects missing required fields', function () {
|
||||
$result = ServerTransferBundle::validate([]);
|
||||
|
||||
expect($result['valid'])->toBeFalse()
|
||||
->and($result['errors'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('validate accepts a minimal valid bundle', function () {
|
||||
$bundle = ServerTransferBundle::wrap([
|
||||
'private_key' => ['private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----"],
|
||||
'server' => [
|
||||
'uuid' => 'srv-1',
|
||||
'name' => 'web',
|
||||
'ip' => '10.0.0.1',
|
||||
'port' => 22,
|
||||
'user' => 'root',
|
||||
],
|
||||
'destinations' => [],
|
||||
'projects' => [],
|
||||
]);
|
||||
|
||||
$result = ServerTransferBundle::validate($bundle);
|
||||
|
||||
expect($result['valid'])->toBeTrue()
|
||||
->and($result['warnings'])->not->toBeEmpty(); // empty destinations warning
|
||||
});
|
||||
|
||||
test('assertValid throws validation exception', function () {
|
||||
ServerTransferBundle::assertValid(['schema_version' => 99]);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('passphrase encrypt decrypt round trip', function () {
|
||||
$original = ServerTransferBundle::wrap([
|
||||
'private_key' => ['private_key' => 'secret-key-material'],
|
||||
'server' => [
|
||||
'uuid' => 'srv-1',
|
||||
'name' => 'web',
|
||||
'ip' => '10.0.0.1',
|
||||
'port' => 22,
|
||||
'user' => 'root',
|
||||
],
|
||||
'destinations' => [['uuid' => 'd1', 'name' => 'coolify', 'network' => 'coolify', 'type' => 'standalone']],
|
||||
'projects' => [],
|
||||
]);
|
||||
|
||||
$encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'correct horse battery staple');
|
||||
|
||||
expect($encrypted['encrypted'])->toBeTrue()
|
||||
->and($encrypted['payload'])->toBeString();
|
||||
|
||||
$restored = ServerTransferBundle::decryptWithPassphrase($encrypted, 'correct horse battery staple');
|
||||
|
||||
expect($restored['export_id'])->toBe($original['export_id'])
|
||||
->and($restored['server']['uuid'])->toBe('srv-1')
|
||||
->and($restored['private_key']['private_key'])->toBe('secret-key-material');
|
||||
});
|
||||
|
||||
test('wrong passphrase fails decrypt', function () {
|
||||
$original = ServerTransferBundle::wrap([
|
||||
'private_key' => ['private_key' => 'x'],
|
||||
'server' => ['uuid' => 's', 'name' => 'n', 'ip' => '1.1.1.1', 'port' => 22, 'user' => 'root'],
|
||||
'destinations' => [],
|
||||
'projects' => [],
|
||||
]);
|
||||
|
||||
$encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'good-pass');
|
||||
|
||||
ServerTransferBundle::decryptWithPassphrase($encrypted, 'bad-pass');
|
||||
})->throws(RuntimeException::class);
|
||||
|
||||
test('app key seal unseal round trip', function () {
|
||||
$original = ['hello' => 'world', 'n' => 1];
|
||||
$sealed = ServerTransferBundle::sealWithAppKey($original);
|
||||
$restored = ServerTransferBundle::unsealWithAppKey($sealed);
|
||||
|
||||
expect($restored)->toBe($original);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$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.77.0.10',
|
||||
'name' => 'claim-me',
|
||||
'server_metadata' => [
|
||||
'transfer' => [
|
||||
'status' => 'imported',
|
||||
'export_id' => 'export-xyz',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('markTransferred writes force_disabled and transfer status together', function () {
|
||||
$result = app(ServerTransferClaimer::class)->markTransferred(
|
||||
$this->server,
|
||||
exportId: 'export-xyz',
|
||||
targetInstanceUrl: 'https://coolify-b.test',
|
||||
);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid);
|
||||
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred')
|
||||
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
|
||||
->and(data_get($this->server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test')
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
|
||||
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('claim persists ownership metadata transactionally without remote write', function () {
|
||||
$result = app(ServerTransferClaimer::class)->claim(
|
||||
$this->server,
|
||||
writeRemote: false,
|
||||
rebindSentinel: true,
|
||||
);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid)
|
||||
->and($result['claim_written'])->toBeFalse()
|
||||
->and($result['sentinel_rebound'])->toBeTrue()
|
||||
->and(data_get($result, 'claim.instance_url'))->toBe('https://coolify-a.test');
|
||||
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and(data_get($this->server->server_metadata, 'transfer.claim_written'))->toBeFalse()
|
||||
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
|
||||
->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test');
|
||||
});
|
||||
@@ -0,0 +1,861 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'name' => 'transfer-key']);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => '10.55.0.10',
|
||||
'name' => 'source-server',
|
||||
'description' => 'Server to export',
|
||||
'port' => 22,
|
||||
'user' => 'root',
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->destination->update(['name' => 'coolify', 'network' => 'coolify']);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Transfer Project']);
|
||||
$this->environment = $this->project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'production']);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'my-app',
|
||||
'git_repository' => 'https://github.com/example/app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::withoutEvents(function () {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->forceFill([
|
||||
'key' => 'APP_SECRET',
|
||||
'value' => 'super-secret-value',
|
||||
'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();
|
||||
});
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-data',
|
||||
'mount_path' => '/app/data',
|
||||
'host_path' => null,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resource_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
ScheduledTask::create([
|
||||
'name' => 'nightly',
|
||||
'command' => 'php artisan schedule:run',
|
||||
'frequency' => '0 0 * * *',
|
||||
'application_id' => $this->application->id,
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
StandalonePostgresql::withoutEvents(function () {
|
||||
$database = new StandalonePostgresql;
|
||||
$database->forceFill([
|
||||
'name' => 'app-db',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'db-password-secret',
|
||||
'postgres_db' => 'app',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
$database->uuid = new_public_id();
|
||||
$database->save();
|
||||
$this->database = $database;
|
||||
});
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'postgres-data-'.$this->database->uuid,
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'resource_type' => $this->database->getMorphClass(),
|
||||
'resource_id' => $this->database->id,
|
||||
]);
|
||||
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'SHARED_API_KEY',
|
||||
'value' => 'shared-secret',
|
||||
'type' => 'server',
|
||||
'server_id' => $this->server->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_literal' => true,
|
||||
]);
|
||||
|
||||
$this->appTag = Tag::create([
|
||||
'name' => 'transfer-demo',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->application->tags()->attach($this->appTag->id);
|
||||
|
||||
$this->dbTag = Tag::create([
|
||||
'name' => 'critical',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->database->tags()->attach($this->dbTag->id);
|
||||
|
||||
$this->backup = ScheduledDatabaseBackup::create([
|
||||
'uuid' => new_public_id(),
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 2 * * *',
|
||||
'databases_to_backup' => 'app',
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'database_id' => $this->database->id,
|
||||
]);
|
||||
|
||||
$this->service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'server_id' => $this->server->id,
|
||||
'name' => 'demo-service',
|
||||
'docker_compose_raw' => "services:\n whoami:\n image: traefik/whoami\n",
|
||||
]);
|
||||
|
||||
$this->serviceTask = ScheduledTask::create([
|
||||
'name' => 'service-ping',
|
||||
'command' => 'echo ping',
|
||||
'frequency' => '*/15 * * * *',
|
||||
'service_id' => $this->service->id,
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$this->exporter = app(ServerTransferExporter::class);
|
||||
$this->importer = app(ServerTransferImporter::class);
|
||||
});
|
||||
|
||||
test('export includes server applications databases and secrets in plaintext', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
expect($bundle['schema_version'])->toBe(ServerTransferBundle::SCHEMA_VERSION)
|
||||
->and($bundle['server']['uuid'])->toBe($this->server->uuid)
|
||||
->and($bundle['server']['ip'])->toBe('10.55.0.10')
|
||||
->and($bundle['private_key']['private_key'])->toContain('BEGIN OPENSSH PRIVATE KEY')
|
||||
->and($bundle['private_key']['fingerprint'])->toBe($this->privateKey->fingerprint)
|
||||
->and($bundle['destinations'])->not->toBeEmpty()
|
||||
->and($bundle['projects'])->toHaveCount(1)
|
||||
->and($bundle['shared_environment_variables']['server'])->toHaveCount(1);
|
||||
|
||||
$environment = $bundle['projects'][0]['environments'][0];
|
||||
expect($environment['applications'])->toHaveCount(1)
|
||||
->and($environment['databases'])->toHaveCount(1)
|
||||
->and($environment['services'])->toHaveCount(1);
|
||||
|
||||
$app = $environment['applications'][0];
|
||||
$secret = collect($app['environment_variables'])->firstWhere('key', 'APP_SECRET');
|
||||
expect($app['uuid'])->toBe($this->application->uuid)
|
||||
->and($app['attributes']['name'])->toBe('my-app')
|
||||
->and($secret)->not->toBeNull()
|
||||
->and($secret['value'])->toBe('super-secret-value')
|
||||
->and($app['persistent_storages'])->toHaveCount(1)
|
||||
->and($app['scheduled_tasks'])->toHaveCount(1)
|
||||
->and($app['tags'])->toHaveCount(1)
|
||||
->and($app['tags'][0]['name'])->toBe('transfer-demo');
|
||||
|
||||
$db = $environment['databases'][0];
|
||||
expect($db['type'])->toBe('StandalonePostgresql')
|
||||
->and($db['attributes']['postgres_password'])->toBe('db-password-secret')
|
||||
->and($db['tags'])->toHaveCount(1)
|
||||
->and($db['tags'][0]['name'])->toBe('critical')
|
||||
->and($db['scheduled_backups'])->toHaveCount(1)
|
||||
->and($db['scheduled_backups'][0]['frequency'])->toBe('0 2 * * *');
|
||||
|
||||
$service = $environment['services'][0];
|
||||
expect($service['uuid'])->toBe($this->service->uuid)
|
||||
->and($service['scheduled_tasks'])->toHaveCount(1)
|
||||
->and($service['scheduled_tasks'][0]['name'])->toBe('service-ping');
|
||||
});
|
||||
|
||||
test('export refuses localhost coolify host', function () {
|
||||
$localhost = Server::factory()->create([
|
||||
'id' => 0,
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => 'host.docker.internal',
|
||||
]);
|
||||
|
||||
$this->exporter->export($localhost);
|
||||
})->throws(RuntimeException::class);
|
||||
|
||||
test('dry run import reports counts without creating server', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$before = Server::count();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: true);
|
||||
|
||||
expect($result['dry_run'])->toBeTrue()
|
||||
->and($result['created']['applications'])->toBe(1)
|
||||
->and($result['created']['databases'])->toBe(1)
|
||||
->and(Server::count())->toBe($before);
|
||||
});
|
||||
|
||||
test('round trip import preserves uuids secrets and related resources after source removal', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$originalServerUuid = $this->server->uuid;
|
||||
$originalAppUuid = $this->application->uuid;
|
||||
$originalDbUuid = $this->database->uuid;
|
||||
$originalDestUuid = $this->destination->uuid;
|
||||
$originalServiceUuid = $this->service->uuid;
|
||||
$originalBackupUuid = $this->backup->uuid;
|
||||
$originalServiceTaskUuid = $this->serviceTask->uuid;
|
||||
$keyMaterial = $this->privateKey->private_key;
|
||||
|
||||
// Simulate handoff: source instance no longer owns the IP / records.
|
||||
$this->service->forceDelete();
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
Tag::query()->delete();
|
||||
ScheduledDatabaseBackup::query()->delete();
|
||||
ScheduledTask::query()->delete();
|
||||
// Same fingerprint cannot exist twice; remove source key so target re-creates it.
|
||||
$this->privateKey->delete();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['dry_run'])->toBeFalse()
|
||||
->and($result['server_uuid'])->toBe($originalServerUuid)
|
||||
->and($result['created']['applications'])->toBe(1)
|
||||
->and($result['created']['databases'])->toBe(1)
|
||||
->and($result['created']['services'])->toBe(1);
|
||||
|
||||
$server = Server::where('uuid', $originalServerUuid)->first();
|
||||
expect($server)->not->toBeNull()
|
||||
->and($server->ip)->toBe('10.55.0.10')
|
||||
->and($server->name)->toBe('source-server')
|
||||
->and($server->privateKey->private_key)->toBe($keyMaterial)
|
||||
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and(data_get($server->server_metadata, 'transfer.adopt_mode'))->toBeTrue();
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->where('uuid', $originalDestUuid)->first();
|
||||
expect($destination)->not->toBeNull();
|
||||
|
||||
$app = Application::where('uuid', $originalAppUuid)->first();
|
||||
expect($app)->not->toBeNull()
|
||||
->and($app->name)->toBe('my-app')
|
||||
->and($app->fqdn)->toBe('https://app.example.com')
|
||||
->and($app->environment_variables()->where('key', 'APP_SECRET')->first()?->value)->toBe('super-secret-value')
|
||||
->and($app->persistentStorages)->toHaveCount(1)
|
||||
->and($app->scheduled_tasks)->toHaveCount(1)
|
||||
->and($app->tags()->pluck('name')->all())->toContain('transfer-demo');
|
||||
|
||||
$db = StandalonePostgresql::where('uuid', $originalDbUuid)->first();
|
||||
expect($db)->not->toBeNull()
|
||||
->and($db->postgres_password)->toBe('db-password-secret')
|
||||
->and($db->persistentStorages)->not->toBeEmpty()
|
||||
->and($db->tags()->pluck('name')->all())->toContain('critical');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $originalBackupUuid)->first();
|
||||
expect($backup)->not->toBeNull()
|
||||
->and($backup->frequency)->toBe('0 2 * * *')
|
||||
->and((bool) $backup->enabled)->toBeTrue()
|
||||
->and($backup->database_id)->toBe($db->id);
|
||||
|
||||
$service = Service::where('uuid', $originalServiceUuid)->first();
|
||||
expect($service)->not->toBeNull()
|
||||
->and($service->name)->toBe('demo-service');
|
||||
|
||||
$serviceTask = ScheduledTask::where('uuid', $originalServiceTaskUuid)->first();
|
||||
expect($serviceTask)->not->toBeNull()
|
||||
->and($serviceTask->service_id)->toBe($service->id)
|
||||
->and($serviceTask->name)->toBe('service-ping')
|
||||
->and($serviceTask->command)->toBe('echo ping');
|
||||
|
||||
$shared = SharedEnvironmentVariable::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('key', 'SHARED_API_KEY')
|
||||
->first();
|
||||
expect($shared)->not->toBeNull()
|
||||
->and($shared->value)->toBe('shared-secret');
|
||||
});
|
||||
|
||||
test('import fails when server ip already exists', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
$this->importer->import($bundle, teamId: $this->team->id);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('import rolls back all created rows when a later resource fails', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$originalServerUuid = $this->server->uuid;
|
||||
|
||||
// Force a failure after private keys / server would have been created.
|
||||
$bundle['projects'][0]['environments'][0]['databases'][] = [
|
||||
'type' => 'NotARealDatabaseType',
|
||||
'uuid' => 'broken-db-uuid',
|
||||
'attributes' => ['name' => 'broken'],
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
];
|
||||
|
||||
$this->service->forceDelete();
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
expect(fn () => $this->importer->import(
|
||||
$bundle,
|
||||
teamId: $this->team->id,
|
||||
dryRun: false,
|
||||
preserveUuids: true,
|
||||
adoptMode: true,
|
||||
claim: false,
|
||||
))->toThrow(RuntimeException::class, 'Unsupported database type');
|
||||
|
||||
expect(Server::where('uuid', $originalServerUuid)->exists())->toBeFalse()
|
||||
->and(PrivateKey::where('uuid', data_get($bundle, 'private_key.uuid'))->exists())->toBeFalse()
|
||||
->and(Application::where('uuid', data_get($bundle, 'projects.0.environments.0.applications.0.uuid'))->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('import fails on invalid schema', function () {
|
||||
$this->importer->import(['schema_version' => 1], teamId: $this->team->id);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('import reuses existing private key fingerprint on same team', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$originalKeyId = $this->privateKey->id;
|
||||
|
||||
// Free the IP without deleting the key.
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id);
|
||||
|
||||
$server = Server::where('uuid', $result['server_uuid'])->first();
|
||||
expect($server->private_key_id)->toBe($originalKeyId);
|
||||
});
|
||||
|
||||
test('encrypted export decrypts for import', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$encrypted = ServerTransferBundle::encryptWithPassphrase($bundle, 'transfer-pass');
|
||||
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
$plain = ServerTransferBundle::decryptWithPassphrase($encrypted, 'transfer-pass');
|
||||
$result = $this->importer->import($plain, teamId: $this->team->id);
|
||||
|
||||
expect(Server::where('uuid', $result['server_uuid'])->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('system-wide github apps are not exported and re-link on import by uuid', function () {
|
||||
$systemWide = new GithubApp;
|
||||
$systemWide->forceFill([
|
||||
'name' => 'System Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$systemWide->uuid = 'system-github-public';
|
||||
$systemWide->save();
|
||||
|
||||
$teamApp = new GithubApp;
|
||||
$teamApp->forceFill([
|
||||
'name' => 'Team GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => false,
|
||||
'team_id' => $this->team->id,
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'Iv1.test',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'hook',
|
||||
]);
|
||||
$teamApp->uuid = 'team-github-app';
|
||||
$teamApp->save();
|
||||
|
||||
$this->application->source_type = GithubApp::class;
|
||||
$this->application->source_id = $systemWide->id;
|
||||
$this->application->save();
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'team-source-app',
|
||||
'git_repository' => 'https://github.com/example/private',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'source_type' => GithubApp::class,
|
||||
'source_id' => $teamApp->id,
|
||||
]);
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
expect($bundle['github_apps'])->toHaveCount(1)
|
||||
->and($bundle['github_apps'][0]['uuid'])->toBe('team-github-app')
|
||||
->and(collect($bundle['warnings'])->implode(' '))->toContain('system-wide GitHub App');
|
||||
|
||||
$appSources = collect($bundle['projects'][0]['environments'][0]['applications'])
|
||||
->mapWithKeys(fn ($a) => [$a['attributes']['name'] => $a['source'] ?? null]);
|
||||
|
||||
expect($appSources['my-app'])->toMatchArray(['type' => 'github_app', 'uuid' => 'system-github-public'])
|
||||
->and($appSources['team-source-app'])->toMatchArray(['type' => 'github_app', 'uuid' => 'team-github-app']);
|
||||
|
||||
// Free server IP / apps for import into same DB.
|
||||
$this->service->forceDelete();
|
||||
$otherApp->forceDelete();
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
GithubApp::where('uuid', 'team-github-app')->delete();
|
||||
// Simulate target instance already having the same system-wide app UUID.
|
||||
$systemWide->delete();
|
||||
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetSystemWide = new GithubApp;
|
||||
$targetSystemWide->forceFill([
|
||||
'name' => 'System Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $targetTeam->id,
|
||||
]);
|
||||
$targetSystemWide->uuid = 'system-github-public';
|
||||
$targetSystemWide->save();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $targetTeam->id, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['created']['github_apps'])->toBe(1);
|
||||
|
||||
$importedSystem = Application::where('name', 'my-app')->first();
|
||||
$importedTeam = Application::where('name', 'team-source-app')->first();
|
||||
$importedGhTeam = GithubApp::where('uuid', 'team-github-app')->first();
|
||||
|
||||
expect($importedSystem)->not->toBeNull()
|
||||
->and($importedSystem->source_type)->toBe(GithubApp::class)
|
||||
->and($importedSystem->source_id)->toBe($targetSystemWide->id)
|
||||
->and($importedTeam->source_id)->toBe($importedGhTeam->id)
|
||||
->and($importedGhTeam->is_system_wide)->toBeFalse()
|
||||
->and(GithubApp::where('is_system_wide', true)->where('uuid', 'system-github-public')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('service nested apps dbs volumes backups and db file storages round-trip', function () {
|
||||
$serviceApp = new ServiceApplication;
|
||||
$serviceApp->forceFill([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'whoami',
|
||||
'human_name' => 'Whoami',
|
||||
'description' => 'demo nested app',
|
||||
'fqdn' => 'https://whoami.example.com',
|
||||
'ports' => '80:80',
|
||||
'exposes' => '80',
|
||||
'image' => 'traefik/whoami:latest',
|
||||
'exclude_from_status' => false,
|
||||
'required_fqdn' => true,
|
||||
'is_log_drain_enabled' => true,
|
||||
'is_gzip_enabled' => false,
|
||||
'is_stripprefix_enabled' => false,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
$serviceApp->uuid = 'svc-app-whoami';
|
||||
$serviceApp->save();
|
||||
|
||||
EnvironmentVariable::withoutEvents(function () use ($serviceApp) {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->forceFill([
|
||||
'key' => 'WHOAMI_NAME',
|
||||
'value' => 'nested-secret',
|
||||
'resourceable_type' => ServiceApplication::class,
|
||||
'resourceable_id' => $serviceApp->id,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
$env->uuid = new_public_id();
|
||||
$env->save();
|
||||
});
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'whoami-data',
|
||||
'mount_path' => '/data',
|
||||
'host_path' => null,
|
||||
'resource_type' => $serviceApp->getMorphClass(),
|
||||
'resource_id' => $serviceApp->id,
|
||||
]);
|
||||
$serviceAppVolume = LocalPersistentVolume::where('name', 'whoami-data')->firstOrFail();
|
||||
|
||||
LocalFileVolume::withoutEvents(function () use ($serviceApp) {
|
||||
$file = new LocalFileVolume;
|
||||
$file->forceFill([
|
||||
'fs_path' => './config.json',
|
||||
'mount_path' => '/app/config.json',
|
||||
'content' => '{"nested":true}',
|
||||
'is_directory' => false,
|
||||
'resource_type' => $serviceApp->getMorphClass(),
|
||||
'resource_id' => $serviceApp->id,
|
||||
]);
|
||||
$file->uuid = 'svc-app-file-1';
|
||||
$file->save();
|
||||
});
|
||||
|
||||
ScheduledVolumeBackup::create([
|
||||
'uuid' => 'svc-app-vol-backup',
|
||||
'backupable_type' => $serviceAppVolume->getMorphClass(),
|
||||
'backupable_id' => $serviceAppVolume->id,
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => '0 4 * * *',
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'disable_local_backup' => false,
|
||||
]);
|
||||
|
||||
$serviceDb = new ServiceDatabase;
|
||||
$serviceDb->forceFill([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'postgres',
|
||||
'human_name' => 'Nested PG',
|
||||
'image' => 'postgres:16',
|
||||
'ports' => '5432',
|
||||
'exposes' => '5432',
|
||||
'public_port' => 15432,
|
||||
'public_port_timeout' => 30,
|
||||
'is_public' => true,
|
||||
'custom_type' => 'postgresql',
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
$serviceDb->uuid = 'svc-db-postgres';
|
||||
$serviceDb->save();
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'svc-pg-data',
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'resource_type' => $serviceDb->getMorphClass(),
|
||||
'resource_id' => $serviceDb->id,
|
||||
]);
|
||||
|
||||
LocalFileVolume::withoutEvents(function () use ($serviceDb) {
|
||||
$file = new LocalFileVolume;
|
||||
$file->forceFill([
|
||||
'fs_path' => './init.sql',
|
||||
'mount_path' => '/docker-entrypoint-initdb.d/init.sql',
|
||||
'content' => 'SELECT 1;',
|
||||
'is_directory' => false,
|
||||
'resource_type' => $serviceDb->getMorphClass(),
|
||||
'resource_id' => $serviceDb->id,
|
||||
]);
|
||||
$file->uuid = 'svc-db-file-1';
|
||||
$file->save();
|
||||
});
|
||||
|
||||
$serviceDbBackup = ScheduledDatabaseBackup::create([
|
||||
'uuid' => 'svc-db-backup-1',
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 3 * * *',
|
||||
'databases_to_backup' => 'postgres',
|
||||
'database_type' => $serviceDb->getMorphClass(),
|
||||
'database_id' => $serviceDb->id,
|
||||
]);
|
||||
|
||||
// Standalone database file storage
|
||||
LocalFileVolume::withoutEvents(function () {
|
||||
$file = new LocalFileVolume;
|
||||
$file->forceFill([
|
||||
'fs_path' => './pg-conf.d',
|
||||
'mount_path' => '/etc/postgresql/conf.d',
|
||||
'content' => null,
|
||||
'is_directory' => true,
|
||||
'resource_type' => $this->database->getMorphClass(),
|
||||
'resource_id' => $this->database->id,
|
||||
]);
|
||||
$file->uuid = 'standalone-db-file-1';
|
||||
$file->save();
|
||||
});
|
||||
|
||||
// Preview + preview volume
|
||||
$preview = new ApplicationPreview;
|
||||
$preview->forceFill([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => 42,
|
||||
'pull_request_html_url' => 'https://github.com/example/app/pull/42',
|
||||
'fqdn' => 'https://pr-42.app.example.com',
|
||||
'status' => 'running:healthy',
|
||||
'git_type' => 'github',
|
||||
]);
|
||||
$preview->uuid = 'preview-pr-42';
|
||||
$preview->save();
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'preview-42-data',
|
||||
'mount_path' => '/app/data',
|
||||
'resource_type' => $preview->getMorphClass(),
|
||||
'resource_id' => $preview->id,
|
||||
]);
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
$envPayload = $bundle['projects'][0]['environments'][0];
|
||||
$servicePayload = $envPayload['services'][0];
|
||||
$nestedApp = collect($servicePayload['applications'])->firstWhere('uuid', 'svc-app-whoami');
|
||||
$nestedDb = collect($servicePayload['databases'])->firstWhere('uuid', 'svc-db-postgres');
|
||||
$appPayload = $envPayload['applications'][0];
|
||||
$dbPayload = $envPayload['databases'][0];
|
||||
|
||||
expect($nestedApp)->not->toBeNull()
|
||||
->and($nestedApp['ports'])->toBe('80:80')
|
||||
->and($nestedApp['exposes'])->toBe('80')
|
||||
->and($nestedApp['environment_variables'])->toHaveCount(1)
|
||||
->and($nestedApp['environment_variables'][0]['value'])->toBe('nested-secret')
|
||||
->and($nestedApp['persistent_storages'])->toHaveCount(1)
|
||||
->and($nestedApp['file_storages'])->toHaveCount(1)
|
||||
->and($nestedApp['file_storages'][0]['content'])->toBe('{"nested":true}')
|
||||
->and($nestedDb)->not->toBeNull()
|
||||
->and($nestedDb['public_port'])->toBe(15432)
|
||||
->and($nestedDb['custom_type'])->toBe('postgresql')
|
||||
->and($nestedDb['persistent_storages'])->toHaveCount(1)
|
||||
->and($nestedDb['file_storages'])->toHaveCount(1)
|
||||
->and($nestedDb['scheduled_backups'])->toHaveCount(1)
|
||||
->and($nestedDb['scheduled_backups'][0]['uuid'])->toBe('svc-db-backup-1')
|
||||
->and($dbPayload['file_storages'])->toHaveCount(1)
|
||||
->and($dbPayload['file_storages'][0]['uuid'])->toBe('standalone-db-file-1')
|
||||
->and($appPayload['previews'])->toHaveCount(1)
|
||||
->and($appPayload['previews'][0]['uuid'])->toBe('preview-pr-42')
|
||||
->and($appPayload['previews'][0]['persistent_storages'])->toHaveCount(1)
|
||||
->and(collect($bundle['volume_backups'])->pluck('uuid')->all())->toContain('svc-app-vol-backup');
|
||||
|
||||
$originalServerUuid = $this->server->uuid;
|
||||
$originalAppUuid = $this->application->uuid;
|
||||
$originalDbUuid = $this->database->uuid;
|
||||
$originalServiceUuid = $this->service->uuid;
|
||||
|
||||
// Free server IP / UUIDs for re-import. Clear soft-deleted previews (unique FQDN)
|
||||
// without firing remote docker cleanup hooks.
|
||||
ApplicationPreview::withoutEvents(function () {
|
||||
ApplicationPreview::withTrashed()->get()->each->forceDelete();
|
||||
});
|
||||
ServiceApplication::withoutEvents(function () {
|
||||
ServiceApplication::withTrashed()->get()->each->forceDelete();
|
||||
});
|
||||
ServiceDatabase::withoutEvents(function () {
|
||||
ServiceDatabase::withTrashed()->get()->each->forceDelete();
|
||||
});
|
||||
DB::table('environment_variables')->delete();
|
||||
DB::table('local_file_volumes')->delete();
|
||||
DB::table('local_persistent_volumes')->delete();
|
||||
DB::table('scheduled_volume_backups')->delete();
|
||||
DB::table('scheduled_database_backups')->delete();
|
||||
DB::table('scheduled_tasks')->delete();
|
||||
DB::table('services')->delete();
|
||||
DB::table('applications')->delete();
|
||||
DB::table('standalone_postgresqls')->delete();
|
||||
DB::table('standalone_dockers')->delete();
|
||||
DB::table('servers')->delete();
|
||||
$this->privateKey->delete();
|
||||
Tag::query()->delete();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['created']['services'])->toBe(1)
|
||||
->and($result['server_uuid'])->toBe($originalServerUuid);
|
||||
|
||||
$importedService = Service::where('uuid', $originalServiceUuid)->first();
|
||||
expect($importedService)->not->toBeNull();
|
||||
|
||||
$importedSvcApp = ServiceApplication::where('uuid', 'svc-app-whoami')->first();
|
||||
expect($importedSvcApp)->not->toBeNull()
|
||||
->and($importedSvcApp->ports)->toBe('80:80')
|
||||
->and($importedSvcApp->exposes)->toBe('80')
|
||||
->and((bool) $importedSvcApp->is_gzip_enabled)->toBeFalse()
|
||||
->and((bool) $importedSvcApp->is_stripprefix_enabled)->toBeFalse()
|
||||
->and($importedSvcApp->environment_variables()->where('key', 'WHOAMI_NAME')->first()?->value)->toBe('nested-secret')
|
||||
->and($importedSvcApp->persistentStorages)->toHaveCount(1)
|
||||
->and($importedSvcApp->fileStorages)->toHaveCount(1)
|
||||
->and($importedSvcApp->fileStorages->first()->content)->toBe('{"nested":true}');
|
||||
|
||||
$importedSvcDb = ServiceDatabase::where('uuid', 'svc-db-postgres')->first();
|
||||
expect($importedSvcDb)->not->toBeNull()
|
||||
->and($importedSvcDb->public_port)->toBe(15432)
|
||||
->and($importedSvcDb->custom_type)->toBe('postgresql')
|
||||
->and($importedSvcDb->persistentStorages)->toHaveCount(1)
|
||||
->and($importedSvcDb->fileStorages)->toHaveCount(1)
|
||||
->and($importedSvcDb->scheduledBackups)->toHaveCount(1)
|
||||
->and($importedSvcDb->scheduledBackups->first()->uuid)->toBe('svc-db-backup-1');
|
||||
|
||||
$importedStandaloneDb = StandalonePostgresql::where('uuid', $originalDbUuid)->first();
|
||||
expect($importedStandaloneDb)->not->toBeNull()
|
||||
->and($importedStandaloneDb->fileStorages)->toHaveCount(1)
|
||||
->and($importedStandaloneDb->fileStorages->first()->uuid)->toBe('standalone-db-file-1');
|
||||
|
||||
$importedApp = Application::where('uuid', $originalAppUuid)->first();
|
||||
expect($importedApp)->not->toBeNull()
|
||||
->and($importedApp->previews)->toHaveCount(1)
|
||||
->and($importedApp->previews->first()->uuid)->toBe('preview-pr-42')
|
||||
->and($importedApp->previews->first()->persistentStorages)->toHaveCount(1);
|
||||
|
||||
$importedVolBackup = ScheduledVolumeBackup::where('uuid', 'svc-app-vol-backup')->first();
|
||||
expect($importedVolBackup)->not->toBeNull()
|
||||
->and($importedVolBackup->frequency)->toBe('0 4 * * *')
|
||||
->and((bool) $importedVolBackup->enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('export refuses servers with additional destinations', function () {
|
||||
$extraDestination = StandaloneDocker::create([
|
||||
'name' => 'extra-net',
|
||||
'network' => 'extra-net-block',
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
$this->application->additional_networks()->attach($extraDestination->id, [
|
||||
'server_id' => $this->server->id,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
expect(fn () => $this->exporter->export($this->server))
|
||||
->toThrow(RuntimeException::class, 'additional destinations');
|
||||
});
|
||||
|
||||
test('system-wide gitlab apps are not exported and re-link on import by uuid', function () {
|
||||
$systemWide = new GitlabApp;
|
||||
$systemWide->forceFill([
|
||||
'name' => 'System Public GitLab',
|
||||
'api_url' => 'https://gitlab.com/api/v4',
|
||||
'html_url' => 'https://gitlab.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$systemWide->uuid = 'system-gitlab-public';
|
||||
$systemWide->save();
|
||||
|
||||
$teamApp = new GitlabApp;
|
||||
$teamApp->forceFill([
|
||||
'name' => 'Team GitLab App',
|
||||
'api_url' => 'https://gitlab.com/api/v4',
|
||||
'html_url' => 'https://gitlab.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => false,
|
||||
'team_id' => $this->team->id,
|
||||
'app_id' => '123',
|
||||
'app_secret' => 'secret',
|
||||
'oauth_id' => 1,
|
||||
'client_id' => 'client',
|
||||
'client_secret' => 'csecret',
|
||||
'group_name' => 'team',
|
||||
]);
|
||||
$teamApp->uuid = 'team-gitlab-app';
|
||||
$teamApp->save();
|
||||
|
||||
$this->application->source_type = GitlabApp::class;
|
||||
$this->application->source_id = $systemWide->id;
|
||||
$this->application->save();
|
||||
|
||||
Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'team-gl-source-app',
|
||||
'git_repository' => 'https://gitlab.com/example/private',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'source_type' => GitlabApp::class,
|
||||
'source_id' => $teamApp->id,
|
||||
]);
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
expect($bundle['gitlab_apps'])->toHaveCount(1)
|
||||
->and($bundle['gitlab_apps'][0]['uuid'])->toBe('team-gitlab-app')
|
||||
->and(collect($bundle['warnings'])->implode(' '))->toContain('system-wide GitLab App');
|
||||
|
||||
$this->service->forceDelete();
|
||||
Application::query()->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
GitlabApp::where('uuid', 'team-gitlab-app')->delete();
|
||||
$systemWide->delete();
|
||||
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetSystemWide = new GitlabApp;
|
||||
$targetSystemWide->forceFill([
|
||||
'name' => 'System Public GitLab',
|
||||
'api_url' => 'https://gitlab.com/api/v4',
|
||||
'html_url' => 'https://gitlab.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $targetTeam->id,
|
||||
]);
|
||||
$targetSystemWide->uuid = 'system-gitlab-public';
|
||||
$targetSystemWide->save();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $targetTeam->id, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['created']['gitlab_apps'])->toBe(1);
|
||||
|
||||
$importedSystem = Application::where('name', 'my-app')->first();
|
||||
$importedTeam = Application::where('name', 'team-gl-source-app')->first();
|
||||
$importedGlTeam = GitlabApp::where('uuid', 'team-gitlab-app')->first();
|
||||
|
||||
expect($importedSystem)->not->toBeNull()
|
||||
->and($importedSystem->source_type)->toBe(GitlabApp::class)
|
||||
->and($importedSystem->source_id)->toBe($targetSystemWide->id)
|
||||
->and($importedTeam->source_id)->toBe($importedGlTeam->id)
|
||||
->and($importedGlTeam->is_system_wide)->toBeFalse()
|
||||
->and(GitlabApp::where('is_system_wide', true)->where('uuid', 'system-gitlab-public')->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferMigrator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$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.88.0.10',
|
||||
'name' => 'migrate-me',
|
||||
]);
|
||||
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = $project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $project->id]);
|
||||
Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'name' => 'app-on-server',
|
||||
'ports_exposes' => '3000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('migrate exports imports via http and completes locally', function () {
|
||||
Http::fake([
|
||||
'http://target.test/api/v1/servers/import' => Http::response([
|
||||
'dry_run' => false,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'claimed' => true,
|
||||
'export_id' => 'remote-export',
|
||||
'created' => ['applications' => 1],
|
||||
'warnings' => [],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$result = app(ServerTransferMigrator::class)->migrate(
|
||||
server: $this->server,
|
||||
targetUrl: 'http://target.test',
|
||||
targetToken: 'target-token-xyz',
|
||||
writeRemote: false,
|
||||
);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid)
|
||||
->and($result['target_url'])->toBe('http://target.test')
|
||||
->and($result['import']['claimed'])->toBeTrue()
|
||||
->and($result['message'])->toContain('migrated');
|
||||
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred')
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'http://target.test/api/v1/servers/import'
|
||||
&& $request->hasHeader('Authorization', 'Bearer target-token-xyz')
|
||||
&& data_get($request->data(), 'claim') === true
|
||||
&& data_get($request->data(), 'bundle.server.uuid') === $this->server->uuid;
|
||||
});
|
||||
});
|
||||
|
||||
test('migrate rewrites localhost target when running in docker style env', function () {
|
||||
// Simulate container: create a temp marker if missing is hard; instead assert host rewrite helper via migrate call
|
||||
// with Http fake matching host.docker.internal when /.dockerenv exists — skip if not in docker.
|
||||
if (! file_exists('/.dockerenv') && ! is_file('/run/.containerenv')) {
|
||||
expect(true)->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Http::fake([
|
||||
'http://host.docker.internal:8001/api/v1/servers/import' => Http::response([
|
||||
'dry_run' => false,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'claimed' => true,
|
||||
'warnings' => [],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://localhost:8001',
|
||||
'token',
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'host.docker.internal:8001'));
|
||||
});
|
||||
|
||||
test('migrate fails clearly when target is unreachable', function () {
|
||||
Http::fake([
|
||||
'http://down.test/*' => Http::failedConnection(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://down.test',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'Could not reach target');
|
||||
});
|
||||
|
||||
test('migrate fails when target returns error', function () {
|
||||
Http::fake([
|
||||
'http://target.test/api/v1/servers/import' => Http::response([
|
||||
'message' => 'A server with IP/domain already exists',
|
||||
], 422),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://target.test',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'Target import failed');
|
||||
|
||||
// Source must remain unmanaged-away only after successful remote import+complete.
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->not->toBe('transferred')
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('migrate surfaces recovery guidance when complete fails after successful remote import', function () {
|
||||
Http::fake([
|
||||
'http://target.test/api/v1/servers/import' => Http::response([
|
||||
'dry_run' => false,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'claimed' => true,
|
||||
'warnings' => [],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$claimer = Mockery::mock(ServerTransferClaimer::class)->makePartial();
|
||||
$claimer->shouldReceive('markTransferred')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('simulated complete failure'));
|
||||
app()->instance(ServerTransferClaimer::class, $claimer);
|
||||
|
||||
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://target.test',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'Retry complete');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Jobs\ValidateAndInstallServerJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->team = Team::factory()->create();
|
||||
$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,
|
||||
]);
|
||||
});
|
||||
|
||||
test('transferred servers cannot be validated', function () {
|
||||
expect($this->server->canBeValidated())->toBeTrue()
|
||||
->and($this->server->isTransferredAway())->toBeFalse();
|
||||
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
$this->server->forceDisableServer();
|
||||
|
||||
expect($this->server->fresh()->isTransferredAway())->toBeTrue()
|
||||
->and($this->server->fresh()->canBeValidated())->toBeFalse();
|
||||
});
|
||||
|
||||
test('forceEnableServer does not clear transferred servers', function () {
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
$this->server->forceDisableServer();
|
||||
|
||||
$this->server->forceEnableServer();
|
||||
|
||||
expect((bool) $this->server->fresh()->settings->force_disabled)->toBeTrue()
|
||||
->and($this->server->fresh()->canBeValidated())->toBeFalse();
|
||||
});
|
||||
|
||||
test('ValidateServer action rejects transferred servers', function () {
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
|
||||
expect(fn () => ValidateServer::run($this->server))
|
||||
->toThrow(Exception::class, 'transferred');
|
||||
});
|
||||
|
||||
test('ValidateAndInstallServerJob no-ops for transferred servers', function () {
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
|
||||
(new ValidateAndInstallServerJob($this->server))->handle();
|
||||
|
||||
expect((bool) $this->server->fresh()->is_validating)->toBeFalse()
|
||||
->and((string) $this->server->fresh()->validation_logs)->toContain('transferred');
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
it('documents optional installation during server validation', function () {
|
||||
$openApi = json_decode(
|
||||
file_get_contents(__DIR__.'/../../openapi.json'),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
$operation = $openApi['paths']['/servers/{uuid}/validate']['post'];
|
||||
$install = $operation['requestBody']['content']['application/json']['schema']['properties']['install'];
|
||||
|
||||
expect($install)
|
||||
->toMatchArray([
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
])
|
||||
->and($install['description'])->toContain('restart the Docker daemon');
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
it('documents POST for service application actions', function () {
|
||||
$openApi = json_decode(
|
||||
file_get_contents(__DIR__.'/../../openapi.json'),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
$actionPaths = [
|
||||
'/services/{uuid}/applications/{app_uuid}/start',
|
||||
'/services/{uuid}/applications/{app_uuid}/restart',
|
||||
'/services/{uuid}/applications/{app_uuid}/stop',
|
||||
];
|
||||
|
||||
foreach ($actionPaths as $path) {
|
||||
expect($openApi['paths'][$path])
|
||||
->toHaveKey('post')
|
||||
->not->toHaveKey('get')
|
||||
->and($openApi['paths'][$path]['post']['responses'])
|
||||
->toHaveKeys(['200', '400', '401', '404', '501']);
|
||||
}
|
||||
|
||||
expect($openApi['paths'][$actionPaths[0]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
|
||||
->toHaveKey('message')
|
||||
->and($openApi['paths'][$actionPaths[1]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
|
||||
->toHaveKey('message')
|
||||
->and($openApi['paths'][$actionPaths[2]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
|
||||
->toHaveKey('message');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
it('documents service database management endpoints', function () {
|
||||
$openApi = json_decode(
|
||||
file_get_contents(__DIR__.'/../../openapi.json'),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
expect($openApi['paths'])
|
||||
->toHaveKey('/services/{uuid}/databases')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}/logs')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}/start')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}/restart')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}/stop')
|
||||
->and($openApi['paths']['/services/{uuid}/databases']['get'])
|
||||
->toBeArray()
|
||||
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}'])
|
||||
->toHaveKeys(['get', 'patch'])
|
||||
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/logs']['get'])
|
||||
->toBeArray()
|
||||
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/start']['post'])
|
||||
->toBeArray()
|
||||
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/restart']['post'])
|
||||
->toBeArray()
|
||||
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/stop']['post'])
|
||||
->toBeArray();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use OpenApi\Attributes\Patch;
|
||||
|
||||
it('only documents fields supported by the update service endpoint', function () {
|
||||
$method = new ReflectionMethod(ServicesController::class, 'update_by_uuid');
|
||||
$patch = $method->getAttributes(Patch::class)[0]->newInstance();
|
||||
$documentedProperties = $patch->requestBody->content[0]->schema->properties;
|
||||
|
||||
$openApi = json_decode(
|
||||
file_get_contents(__DIR__.'/../../openapi.json'),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
$generatedProperties = $openApi['paths']['/services/{uuid}']['patch']['requestBody']['content']['application/json']['schema']['properties'];
|
||||
|
||||
$unsupportedFields = [
|
||||
'project_uuid',
|
||||
'environment_name',
|
||||
'environment_uuid',
|
||||
'server_uuid',
|
||||
'destination_uuid',
|
||||
];
|
||||
|
||||
expect(array_keys($generatedProperties))->toBe(array_keys($documentedProperties))
|
||||
->and(array_intersect($unsupportedFields, array_keys($documentedProperties)))->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Runtime Logs must query Docker with the label that matches the resource type.
|
||||
* Applications use coolify.applicationId, services coolify.serviceId, databases coolify.databaseId.
|
||||
* Using the application helper for services/databases returns empty results even when containers run.
|
||||
*/
|
||||
it('looks up containers with the correct helper for each resource type', function () {
|
||||
$logsFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Shared/Logs.php');
|
||||
|
||||
expect($logsFile)
|
||||
->toContain('getCurrentApplicationContainerStatus(')
|
||||
->toContain('getCurrentServiceContainerStatus(')
|
||||
->toContain('getCurrentDatabaseContainerStatus(')
|
||||
->toContain('$this->resource instanceof Application')
|
||||
->toContain('$this->resource instanceof Service');
|
||||
});
|
||||
|
||||
it('filters application containers by coolify.applicationId', function () {
|
||||
$dockerHelpers = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
|
||||
|
||||
expect($dockerHelpers)->toContain('label=coolify.applicationId=');
|
||||
});
|
||||
|
||||
it('filters service and database containers by the correct coolify labels', function () {
|
||||
$dockerHelpers = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
|
||||
|
||||
expect($dockerHelpers)
|
||||
->toContain('function getCurrentServiceContainerStatus')
|
||||
->toContain('label=coolify.serviceId=')
|
||||
->toContain('function getCurrentDatabaseContainerStatus')
|
||||
->toContain('label=coolify.databaseId=');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Regression for #11079 / #11030: clearing domains normalizes to null, then
|
||||
* sslipDomainWarning must accept null without a TypeError.
|
||||
*/
|
||||
it('returns false when domains are null', function () {
|
||||
expect(sslipDomainWarning(null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false when domains are empty or whitespace', function () {
|
||||
expect(sslipDomainWarning(''))->toBeFalse();
|
||||
expect(sslipDomainWarning(' '))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for non-sslip https domains', function () {
|
||||
expect(sslipDomainWarning('https://example.com'))->toBeFalse();
|
||||
expect(sslipDomainWarning('http://app.example.com,https://www.example.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for http sslip domains without https', function () {
|
||||
expect(sslipDomainWarning('http://app.127.0.0.1.sslip.io'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true when any domain uses https with sslip', function () {
|
||||
expect(sslipDomainWarning('https://app.127.0.0.1.sslip.io'))->toBeTrue();
|
||||
expect(sslipDomainWarning('https://example.com,https://app.127.0.0.1.sslip.io'))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
test('sentinel startup regenerates an empty endpoint from instance settings', function () {
|
||||
$action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
|
||||
$component = file_get_contents(dirname(__DIR__, 2).'/app/Livewire/Server/Sentinel.php');
|
||||
|
||||
expect($action)
|
||||
->toContain('ensureSentinelUrl()')
|
||||
->and($component)
|
||||
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;')
|
||||
->and(file_get_contents(dirname(__DIR__, 2).'/app/Models/ServerSetting.php'))
|
||||
->toContain('generateSentinelUrl(ignoreEvent: true)')
|
||||
->toContain('sentinelUrlFromCurrentRequest()')
|
||||
->toContain('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.')
|
||||
->and($component)
|
||||
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
test('traefikServiceNameHash is stable and length 4', function () {
|
||||
expect(traefikServiceNameHash('api.test'))->toBe(substr(md5('api.test'), 0, 4))
|
||||
->and(traefikServiceNameHash('api.test'))->toHaveLength(4)
|
||||
->and(traefikServiceNameHash('api.test'))->not->toBe(traefikServiceNameHash('api-test'));
|
||||
});
|
||||
|
||||
test('traefikSafeServiceNameSegment replaces dots and always appends hash', function () {
|
||||
$segment = traefikSafeServiceNameSegment('api.test');
|
||||
$hash = traefikServiceNameHash('api.test');
|
||||
|
||||
expect($segment)->toBe("api-test-{$hash}")
|
||||
->and($segment)->not->toContain('.')
|
||||
->and(str_ends_with($segment, "-{$hash}"))->toBeTrue();
|
||||
});
|
||||
|
||||
test('traefikSafeServiceNameSegment keeps api.test and api-test distinct', function () {
|
||||
$dot = traefikSafeServiceNameSegment('api.test');
|
||||
$hyphen = traefikSafeServiceNameSegment('api-test');
|
||||
|
||||
expect($dot)->not->toBe($hyphen)
|
||||
->and($dot)->toStartWith('api-test-')
|
||||
->and($hyphen)->toStartWith('api-test-');
|
||||
});
|
||||
|
||||
test('traefikSafeServiceNameSegment normalizes underscores and odd characters', function () {
|
||||
$segment = traefikSafeServiceNameSegment('web_api.v2');
|
||||
$hash = traefikServiceNameHash('web_api.v2');
|
||||
|
||||
expect($segment)->toBe("web-api-v2-{$hash}")
|
||||
->and($segment)->not->toContain('.')
|
||||
->and($segment)->not->toContain('_');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik embeds safe hashed service segment without dotted router names', function () {
|
||||
$uuid = 'testuuid1234';
|
||||
$hashDot = traefikServiceNameHash('api.test');
|
||||
$hashHyphen = traefikServiceNameHash('api-test');
|
||||
|
||||
$dotLabels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: collect(['https://dot.example.com']),
|
||||
is_force_https_enabled: true,
|
||||
service_name: 'api.test',
|
||||
image: 'nginx:alpine',
|
||||
);
|
||||
|
||||
$hyphenLabels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: collect(['https://hyphen.example.com']),
|
||||
is_force_https_enabled: true,
|
||||
service_name: 'api-test',
|
||||
image: 'nginx:alpine',
|
||||
);
|
||||
|
||||
$dotHttps = $dotLabels->first(
|
||||
fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashDot}.rule=")
|
||||
);
|
||||
$hyphenHttps = $hyphenLabels->first(
|
||||
fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashHyphen}.rule=")
|
||||
);
|
||||
|
||||
expect($dotHttps)->not->toBeNull()
|
||||
->and($hyphenHttps)->not->toBeNull()
|
||||
->and($dotHttps)->not->toContain('api.test')
|
||||
->and($dotHttps)->not->toBe($hyphenHttps)
|
||||
->and($hashDot)->not->toBe($hashHyphen);
|
||||
|
||||
$ruleLines = $dotLabels->merge($hyphenLabels)->filter(
|
||||
fn (string $line) => str_contains($line, 'traefik.http.routers.') && str_contains($line, '.rule=')
|
||||
);
|
||||
|
||||
// Traefik label path must stay at traefik.http.routers.{name}.rule (5 segments).
|
||||
foreach ($ruleLines as $line) {
|
||||
$key = str($line)->before('=')->toString();
|
||||
expect(explode('.', $key))->toHaveCount(5);
|
||||
}
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik hyphenated services also receive a hash suffix', function () {
|
||||
$uuid = 'appuuid';
|
||||
$hash = traefikServiceNameHash('another-service');
|
||||
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: collect(['https://another.example.com']),
|
||||
service_name: 'another-service',
|
||||
image: 'nginx:alpine',
|
||||
);
|
||||
|
||||
$routerLine = $labels->first(
|
||||
fn (string $line) => str_contains($line, 'traefik.http.routers.https-0-') && str_contains($line, '.rule=')
|
||||
);
|
||||
|
||||
expect($routerLine)->toContain("https-0-{$uuid}-another-service-{$hash}.rule=");
|
||||
});
|
||||
@@ -44,6 +44,29 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('does not provision a flux laravel api token before v5 production activation', function (string $path) {
|
||||
$source = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($source)->not->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN');
|
||||
})->with([
|
||||
'production env' => '.env.production',
|
||||
'stable install' => 'scripts/install.sh',
|
||||
'nightly install' => 'other/nightly/install.sh',
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('does not provision flux storage before v5 production activation', function (string $path) {
|
||||
$source = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
expect($source)->not->toContain('/data/coolify/flux');
|
||||
})->with([
|
||||
'stable install' => 'scripts/install.sh',
|
||||
'nightly install' => 'other/nightly/install.sh',
|
||||
'stable upgrade' => 'scripts/upgrade.sh',
|
||||
'nightly upgrade' => 'other/nightly/upgrade.sh',
|
||||
]);
|
||||
|
||||
it('uses the selected registry url when extracting upgrade images', function (string $path) {
|
||||
$script = file_get_contents(getcwd().'/'.$path);
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function createAgentTokenIssuerKeypair(): array
|
||||
{
|
||||
$directory = storage_path('framework/testing/agent-token-keys-'.bin2hex(random_bytes(4)));
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$privateKeyPath = $directory.'/jwt.priv';
|
||||
$publicKeyPath = $directory.'/jwt.pub';
|
||||
|
||||
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
chmod($privateKeyPath, 0600);
|
||||
|
||||
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
|
||||
return [$privateKeyPath, $publicKeyPath];
|
||||
}
|
||||
|
||||
function decodeAgentToken(string $token, string $publicKeyPath, ?stdClass &$headers = null): stdClass
|
||||
{
|
||||
// Firebase's JWT::decode only populates $headers when it is non-null on
|
||||
// entry (it opts out of clobbering by default), so seed a placeholder.
|
||||
if (func_num_args() >= 3) {
|
||||
$headers = new stdClass;
|
||||
}
|
||||
|
||||
return JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'), $headers);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
[$this->privateKeyPath, $this->publicKeyPath] = createAgentTokenIssuerKeypair();
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
});
|
||||
|
||||
it('mints the explicit advertised capability list by default, not the wildcard profile', function () {
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
$claims = decodeAgentToken($token, $this->publicKeyPath);
|
||||
|
||||
expect((array) $claims->caps)->toBe(config('flux.host_capabilities'))
|
||||
->and((array) $claims->caps)->toContain('containers.create')
|
||||
->and((array) $claims->caps)->toContain('ingress.apply')
|
||||
->and((array) $claims->caps)->toContain('host.jwt.set')
|
||||
->and((array) $claims->caps)->not->toContain('host-agent:default')
|
||||
->and((array) $claims->caps)->not->toContain('host-agent:dev')
|
||||
->and((array) $claims->caps)->not->toContain('*');
|
||||
});
|
||||
|
||||
it('mints the escape-hatch capability profile when configured', function () {
|
||||
Config::set('flux.host_capability_profile', 'host-agent:default');
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
$claims = decodeAgentToken($token, $this->publicKeyPath);
|
||||
|
||||
expect((array) $claims->caps)->toBe(['host-agent:default']);
|
||||
});
|
||||
|
||||
it('includes a unique jti on every minted token', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
|
||||
$first = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
$second = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
|
||||
expect($first->jti)->toBeString()->not->toBe('')
|
||||
->and($second->jti)->toBeString()->not->toBe('')
|
||||
->and($first->jti)->not->toBe($second->jti);
|
||||
});
|
||||
|
||||
it('derives the token lifetime from config', function () {
|
||||
Config::set('flux.host_token_ttl', 1234);
|
||||
|
||||
$before = time();
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
|
||||
expect($claims->exp - $claims->iat)->toBe(1234)
|
||||
->and($claims->exp)->toBeGreaterThanOrEqual($before + 1234);
|
||||
});
|
||||
|
||||
it('defaults host token lifetime to the flux max token lifetime', function () {
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
|
||||
expect($claims->exp - $claims->iat)->toBe(3600)
|
||||
->and(config('flux.host_token_refresh_threshold'))->toBeLessThan(config('flux.host_token_ttl'));
|
||||
});
|
||||
|
||||
it('clamps a below-floor ttl to 60 seconds', function () {
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10', null, 5), $this->publicKeyPath);
|
||||
|
||||
expect($claims->exp - $claims->iat)->toBe(60);
|
||||
});
|
||||
|
||||
it('sets the configured kid header for future key rotation', function () {
|
||||
Config::set('flux.jwt_kid', 'flux-2026');
|
||||
|
||||
$headers = null;
|
||||
decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath, $headers);
|
||||
|
||||
expect($headers->kid)->toBe('flux-2026')
|
||||
->and($headers->alg)->toBe('ES256');
|
||||
});
|
||||
|
||||
it('refuses to mint a host token before the server has a stable Flux host id', function () {
|
||||
$server = new V5Server([
|
||||
'node_address' => '203.0.113.10',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
expect(fn () => app(AgentTokenIssuer::class)->issueForServer($server))
|
||||
->toThrow(RuntimeException::class, 'Server is missing a valid Flux host id.');
|
||||
});
|
||||
|
||||
it('mints server host tokens for the stable server uuid, not the WireGuard management IP', function () {
|
||||
$server = new V5Server([
|
||||
'uuid' => 'server_abc123',
|
||||
'team_id' => 12,
|
||||
'cluster_id' => 34,
|
||||
'node_address' => '203.0.113.10',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issueForServer($server), $this->publicKeyPath);
|
||||
|
||||
expect($claims->sub)->toBe('server_abc123')
|
||||
->and($claims->server_id)->toBe('server_abc123')
|
||||
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
|
||||
});
|
||||
|
||||
it('warns when the private key file has insecure permissions', function () {
|
||||
chmod($this->privateKeyPath, 0644);
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(fn (string $message, array $context) => str_contains($message, 'insecure permissions')
|
||||
&& $context['expected'] === '0600');
|
||||
|
||||
app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createBroadcastPayloadServer(array $attributes = []): V5Server
|
||||
{
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
return 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.10',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
it('serializes a null cluster payload when the cluster is gone before the queued broadcast runs', function () {
|
||||
[, $team] = createV5UserWithTeam();
|
||||
|
||||
$event = new V5ClusterUpdated($team->id, 999);
|
||||
|
||||
expect($event->broadcastWith())->toBe(['cluster' => null]);
|
||||
});
|
||||
|
||||
it('omits the caddy ingress payload for a non-ingress server', function () {
|
||||
$server = createBroadcastPayloadServer(['is_ingress' => false]);
|
||||
|
||||
$event = new V5CanvasResourceUpdated($server->team_id, null, $server->id);
|
||||
|
||||
expect($event->broadcastWith()['caddyIngress'])->toBeNull();
|
||||
});
|
||||
|
||||
it('serializes the caddy ingress payload for an ingress server', function () {
|
||||
$server = createBroadcastPayloadServer([
|
||||
'is_ingress' => true,
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => 'running',
|
||||
]);
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated($server->team_id, null, $server->id))->broadcastWith();
|
||||
|
||||
expect($payload['caddyIngress'])->toBe(
|
||||
app(CanvasResourceSerializer::class)->serializeCaddyIngress($server->fresh()),
|
||||
)->and($payload['caddyIngress']['id'])->toBe($server->uuid);
|
||||
});
|
||||
|
||||
it('broadcasts the same application shape as the initial-load canvas serializer', function () {
|
||||
$server = createBroadcastPayloadServer();
|
||||
$team = $server->team;
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Payload Parity', 'production');
|
||||
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $server->created_by_user_id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-parity',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'container-123',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->domains()->create(['domain' => 'parity.example.com']);
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
|
||||
|
||||
$expected = app(CanvasResourceSerializer::class)->serializeApplication(
|
||||
V5Application::query()->with(['server', 'domains'])->findOrFail($application->id),
|
||||
);
|
||||
|
||||
expect($payload['application'])->toBe($expected)
|
||||
->and($payload['application']['id'])->toBe($application->uuid)
|
||||
->and($payload['application']['domains'])->toBe(['parity.example.com'])
|
||||
->and($payload['application']['projectUuid'])->toBe($project->uuid)
|
||||
->and($payload['application']['environmentUuid'])->toBe($environment->uuid);
|
||||
});
|
||||
@@ -0,0 +1,744 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Proxy\GenerateCaddyIngressConfiguration;
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\ApplicationDomain;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('generates a caddy ingress compose file with health endpoint and application routes', function () {
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain([
|
||||
'domain' => 'nginx.example.com',
|
||||
]),
|
||||
new ApplicationDomain([
|
||||
'domain' => 'www.nginx.example.com',
|
||||
]),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['compose'])->toContain('container_name: coolify-v5-caddy')
|
||||
->and($configuration['compose'])->toContain("image: 'docker.io/library/caddy:2-alpine'")
|
||||
->and($configuration['compose'])->toContain('80:80')
|
||||
->and($configuration['compose'])->not->toContain('443:443')
|
||||
->and($configuration['compose'])->toContain('./Caddyfile:/etc/caddy/Caddyfile:ro')
|
||||
->and($configuration['compose'])->toContain('./apps:/etc/caddy/apps:ro')
|
||||
->and($configuration['caddyfile'])->toContain('respond /coolify-health 200')
|
||||
->and($configuration['caddyfile'])->toContain('respond 404')
|
||||
->and($configuration['caddyfile'])->toContain('import apps/*.caddy')
|
||||
->and($configuration['apps'])->toHaveCount(1)
|
||||
->and($configuration['apps'][0]['caddyfile'])->toContain('http://nginx.example.com {')
|
||||
->and($configuration['apps'][0]['caddyfile'])->toContain('http://www.nginx.example.com {')
|
||||
->and($configuration['apps'][0]['caddyfile'])->not->toContain('https://')
|
||||
->and($configuration['apps'][0]['caddyfile'])->toContain('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080');
|
||||
});
|
||||
|
||||
it('generates byte-identical caddy routes for valid domains', function () {
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'nginx.example.com']),
|
||||
new ApplicationDomain(['domain' => 'www.nginx.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
|
||||
'http://nginx.example.com {',
|
||||
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
|
||||
'}',
|
||||
'',
|
||||
'http://www.nginx.example.com {',
|
||||
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
|
||||
'}',
|
||||
]));
|
||||
});
|
||||
|
||||
it('never emits domains with caddyfile injection payloads into the configuration', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => "evil.com {\n} http://x"]),
|
||||
new ApplicationDomain(['domain' => "evil\n.com"]),
|
||||
new ApplicationDomain(['domain' => 'safe.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toHaveCount(1)
|
||||
->and($configuration['apps'][0]['caddyfile'])->not->toContain('evil')
|
||||
->and($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
|
||||
'http://safe.example.com {',
|
||||
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
|
||||
'}',
|
||||
]));
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
|
||||
->twice();
|
||||
});
|
||||
|
||||
it('skips whole applications when every domain is malicious', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'evil.com { respond "pwned" }']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('never emits unsafe container names or namespaces into the configuration', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => "coolify {\n} injected",
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'app.example.com']),
|
||||
]));
|
||||
|
||||
$namespaceApplication = new Application([
|
||||
'name' => 'nginx-test-2',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => "default {\n}",
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$namespaceApplication->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'other.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application, $namespaceApplication]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an unsafe container name or namespace.', Mockery::type('array'))
|
||||
->twice();
|
||||
});
|
||||
|
||||
it('never emits out-of-range internal ports into the configuration', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
]);
|
||||
$application->internal_port = 70000;
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'app.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an out-of-range internal port.', Mockery::type('array'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('does not generate app routes for applications without domains', function () {
|
||||
$application = new Application([
|
||||
'name' => 'private-app',
|
||||
'container_name' => 'coolify-v5-private',
|
||||
'mesh_namespace' => 'default',
|
||||
]);
|
||||
$application->setRelation('domains', new Collection);
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['caddyfile'])
|
||||
->toContain('respond /coolify-health 200')
|
||||
->and($configuration['apps'])->toBe([]);
|
||||
});
|
||||
|
||||
it('does not generate app routes when ingress is disabled even with domains', function () {
|
||||
$application = new Application([
|
||||
'name' => 'private-app',
|
||||
'container_name' => 'coolify-v5-private',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => false,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain([
|
||||
'domain' => 'private.example.com',
|
||||
]),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
});
|
||||
|
||||
it('does not generate app routes when the internal port is missing', function () {
|
||||
$application = new Application([
|
||||
'name' => 'needs-port',
|
||||
'container_name' => 'coolify-v5-needs-port',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => null,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain([
|
||||
'domain' => 'needs-port.example.com',
|
||||
]),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
});
|
||||
|
||||
it('applies caddy ingress configuration through flux instead of ssh', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'node_address' => '10.0.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::type('string'),
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')),
|
||||
[]
|
||||
)
|
||||
->andReturn('Caddy ingress applied.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), [
|
||||
'id' => 'v5-caddy-ingress:80',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 80,
|
||||
])
|
||||
->andReturn('Firewall rule applied.');
|
||||
$fluxClient
|
||||
->shouldNotReceive('applyFirewallRule')
|
||||
->with(Mockery::type('string'), Mockery::on(fn (array $rule): bool => ($rule['port'] ?? null) === 443));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$result = StartCaddyIngress::run($server);
|
||||
|
||||
expect($result)->toBe('Caddy ingress applied.');
|
||||
});
|
||||
|
||||
it('does not start caddy ingress for non-ingress servers', function () {
|
||||
$server = new Server([
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
$result = StartCaddyIngress::run($server);
|
||||
|
||||
expect($result)->toBe('Server is not an ingress server.');
|
||||
});
|
||||
|
||||
it('stops caddy ingress through flux instead of ssh', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'node_address' => '10.0.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
|
||||
->andReturn('Firewall rule removed.');
|
||||
$fluxClient
|
||||
->shouldNotReceive('revokeFirewallRule')
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:443');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$result = StopCaddyIngress::run($server);
|
||||
|
||||
expect($result)->toBe('Caddy ingress stopped.');
|
||||
});
|
||||
|
||||
it('does not stop caddy ingress for servers that never had ingress', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldNotReceive('stopIngress');
|
||||
$fluxClient->shouldNotReceive('revokeFirewallRule');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(StopCaddyIngress::run($server))->toBe('Server is not an ingress server.');
|
||||
});
|
||||
|
||||
it('revokes the caddy firewall rule before stopping the ingress container', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
|
||||
->ordered()
|
||||
->andReturn('Firewall rule removed.');
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->ordered()
|
||||
->andThrow(new RuntimeException('podman rm failed'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(fn () => StopCaddyIngress::run($server))->toThrow(RuntimeException::class, 'podman rm failed');
|
||||
});
|
||||
|
||||
it('tolerates already-revoked caddy firewall rules when stopping ingress', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('firewall rule not found'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
|
||||
});
|
||||
|
||||
it('reports an unknown ingress status until coold confirms one', function () {
|
||||
$installed = new Server(['capabilities' => ['ingress']]);
|
||||
$installed->status = 'installed';
|
||||
|
||||
expect($installed->ingressStatus())->toBe('unknown');
|
||||
|
||||
$running = new Server(['capabilities' => ['ingress'], 'ingress_status' => 'running']);
|
||||
|
||||
expect($running->ingressStatus())->toBe('running');
|
||||
});
|
||||
|
||||
it('includes flux error response details when dispatch returns a non success status', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'error',
|
||||
'code' => 500,
|
||||
'message' => 'start Caddy ingress: podman exited with status 125',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
withFakeFluxSocket(
|
||||
"HTTP/1.1 500 Internal Server Error\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
|
||||
);
|
||||
})->throws(RuntimeException::class, 'start Caddy ingress: podman exited with status 125');
|
||||
|
||||
it('uses a friendly message when flux returns an invalid http response', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
withFakeFluxSocket(
|
||||
'',
|
||||
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
|
||||
);
|
||||
})->throws(RuntimeException::class, 'Flux did not return a response before the timeout.');
|
||||
|
||||
it('uses separate flux timeouts for health checks and command dispatches', function () {
|
||||
expect(config('flux.health_timeout_seconds'))->toBe(1.0)
|
||||
->and(config('flux.connection_timeout_seconds'))->toBe(1.0)
|
||||
->and(config('flux.dispatch_timeout_seconds'))->toBe(35.0);
|
||||
});
|
||||
|
||||
function withFakeFluxSocket(string $response, Closure $callback): void
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$connection = stream_socket_accept($server, 5);
|
||||
|
||||
if ($connection !== false) {
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
fwrite($connection, $response);
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
fclose($server);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($server);
|
||||
Config::set('flux.unix_socket_path', $socketPath);
|
||||
Config::set('flux.health_timeout_seconds', 1.0);
|
||||
Config::set('flux.connection_timeout_seconds', 1.0);
|
||||
Config::set('flux.dispatch_timeout_seconds', 1.0);
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
pcntl_waitpid($pid, $status);
|
||||
@unlink($socketPath);
|
||||
}
|
||||
}
|
||||
|
||||
it('dispatches container inventory through the containers list primitive', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'ok',
|
||||
'data' => [],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
|
||||
|
||||
withFakeFluxSocketCapturingRequest(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
$requestPath,
|
||||
fn () => (new FluxClient)->listContainers('100.64.0.10')
|
||||
);
|
||||
|
||||
$request = file_get_contents($requestPath) ?: '';
|
||||
@unlink($requestPath);
|
||||
|
||||
expect($request)->toContain('"type":"containers.list"')
|
||||
->not->toContain('list_containers');
|
||||
});
|
||||
|
||||
it('dispatches image pull and container lifecycle primitives for v5 apps', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responses = [
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Image pulled.']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['id' => 'container-123']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container started.']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['State' => ['Running' => true]]],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container stopped.']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container removed.']],
|
||||
];
|
||||
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
|
||||
|
||||
withFakeFluxSocketCapturingRequests($responses, $requestPath, function (): void {
|
||||
$fluxClient = new FluxClient;
|
||||
|
||||
expect($fluxClient->pullImage('100.64.0.10', 'docker.io/library/nginx:alpine'))->toBe('Image pulled.')
|
||||
->and($fluxClient->createContainer('100.64.0.10', [
|
||||
'name' => 'coolify-v5-nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'networks' => ['coolify-default-mesh'],
|
||||
'network_aliases' => ['coolify-v5-nginx-test'],
|
||||
'dns' => ['10.210.0.1'],
|
||||
'dns_search' => ['default.coolify.internal'],
|
||||
'restart_policy' => 'unless-stopped',
|
||||
]))->toBe('container-123')
|
||||
->and($fluxClient->startContainer('100.64.0.10', 'container-123'))->toBe('Container started.')
|
||||
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]])
|
||||
->and($fluxClient->stopContainer('100.64.0.10', 'container-123'))->toBe('Container stopped.')
|
||||
->and($fluxClient->removeContainer('100.64.0.10', 'container-123', true))->toBe('Container removed.');
|
||||
});
|
||||
|
||||
$request = file_get_contents($requestPath) ?: '';
|
||||
@unlink($requestPath);
|
||||
|
||||
expect($request)->toContain('"type":"images.pull"')
|
||||
->toContain('"type":"containers.create"')
|
||||
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
|
||||
->toContain('"dns_search":["default.coolify.internal"]')
|
||||
->toContain('"type":"containers.start"')
|
||||
->toContain('"type":"containers.inspect"')
|
||||
->toContain('"type":"containers.stop"')
|
||||
->toContain('"type":"containers.delete"')
|
||||
->toContain('"force":true');
|
||||
});
|
||||
|
||||
it('dispatches firewall allow through the firewall allow primitive', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'ok',
|
||||
'data' => ['id' => 'rule-123', 'output' => 'Firewall rule applied.'],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
|
||||
|
||||
withFakeFluxSocketCapturingRequest(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
$requestPath,
|
||||
fn () => (new FluxClient)->applyFirewallRule('100.64.0.10', [
|
||||
'id' => 'rule-123',
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => 5432,
|
||||
])
|
||||
);
|
||||
|
||||
$request = file_get_contents($requestPath) ?: '';
|
||||
@unlink($requestPath);
|
||||
|
||||
expect($request)->toContain('"type":"firewall.allow"')
|
||||
->toContain('"id":"rule-123"')
|
||||
->toContain('"src":"coolify-v5-api"')
|
||||
->toContain('"dst":"coolify-v5-postgres"')
|
||||
->toContain('"port":5432');
|
||||
});
|
||||
|
||||
it('dispatches firewall revoke through the firewall revoke primitive', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'ok',
|
||||
'data' => ['output' => 'Firewall rule removed.'],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
|
||||
|
||||
withFakeFluxSocketCapturingRequest(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
$requestPath,
|
||||
fn () => (new FluxClient)->revokeFirewallRule('100.64.0.10', 'rule-123')
|
||||
);
|
||||
|
||||
$request = file_get_contents($requestPath) ?: '';
|
||||
@unlink($requestPath);
|
||||
|
||||
expect($request)->toContain('"type":"firewall.revoke"')
|
||||
->toContain('"id":"rule-123"');
|
||||
});
|
||||
|
||||
function withFakeFluxSocketCapturingRequests(array $responsePayloads, string $requestPath, Closure $callback): void
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$capturedRequests = '';
|
||||
|
||||
foreach ($responsePayloads as $payload) {
|
||||
$connection = stream_socket_accept($server, 5);
|
||||
|
||||
if ($connection === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
|
||||
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
|
||||
|
||||
while ($remaining > 0 && ! feof($connection)) {
|
||||
$chunk = fread($connection, $remaining);
|
||||
$request .= $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
$capturedRequests .= $request."\n---REQUEST---\n";
|
||||
$body = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n{$body}");
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
file_put_contents($requestPath, $capturedRequests);
|
||||
fclose($server);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($server);
|
||||
Config::set('flux.unix_socket_path', $socketPath);
|
||||
Config::set('flux.health_timeout_seconds', 1.0);
|
||||
Config::set('flux.connection_timeout_seconds', 1.0);
|
||||
Config::set('flux.dispatch_timeout_seconds', 1.0);
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
pcntl_waitpid($pid, $status);
|
||||
@unlink($socketPath);
|
||||
}
|
||||
}
|
||||
|
||||
function withFakeFluxSocketCapturingRequest(string $response, string $requestPath, Closure $callback): void
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$connection = stream_socket_accept($server, 5);
|
||||
|
||||
if ($connection !== false) {
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
|
||||
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
|
||||
|
||||
while ($remaining > 0 && ! feof($connection)) {
|
||||
$chunk = fread($connection, $remaining);
|
||||
$request .= $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($requestPath, $request);
|
||||
fwrite($connection, $response);
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
fclose($server);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($server);
|
||||
|
||||
try {
|
||||
config(['flux.unix_socket_path' => $socketPath]);
|
||||
$callback();
|
||||
pcntl_waitpid($pid, $status);
|
||||
} finally {
|
||||
@unlink($socketPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('detects unsupported coold verbs from flux http 501 responses', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
// Flux rejects verbs the node's coold did not advertise with HTTP 501 and
|
||||
// "primitive <verb> is not supported by host" (coold repo:
|
||||
// flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245).
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'error',
|
||||
'code' => 501,
|
||||
'message' => 'primitive firewall.allow is not supported by host',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$caught = null;
|
||||
|
||||
withCooldVerbContractFluxSocket(
|
||||
"HTTP/1.1 501 Not Implemented\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
function () use (&$caught): void {
|
||||
try {
|
||||
(new FluxClient)->applyFirewallRule('100.64.0.10', [
|
||||
'id' => 'rule-1',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 80,
|
||||
]);
|
||||
} catch (RuntimeException $exception) {
|
||||
$caught = $exception;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
|
||||
->and($caught->verb)->toBe('firewall.allow')
|
||||
->and($caught->getMessage())->toBe('primitive firewall.allow is not supported by host');
|
||||
});
|
||||
|
||||
it('detects unsupported coold verbs from the flux message even without a 501 status', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'error',
|
||||
'code' => 502,
|
||||
'message' => 'primitive coold.logs is not supported by host',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$caught = null;
|
||||
|
||||
withCooldVerbContractFluxSocket(
|
||||
"HTTP/1.1 502 Bad Gateway\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
function () use (&$caught): void {
|
||||
try {
|
||||
(new FluxClient)->cooldLogs('100.64.0.10');
|
||||
} catch (RuntimeException $exception) {
|
||||
$caught = $exception;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
|
||||
->and($caught->verb)->toBe('coold.logs');
|
||||
});
|
||||
|
||||
it('keeps generic flux dispatch failures as plain runtime exceptions', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'error',
|
||||
'code' => 500,
|
||||
'message' => 'apply firewall rule: nft exited with status 1',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$caught = null;
|
||||
|
||||
withCooldVerbContractFluxSocket(
|
||||
"HTTP/1.1 500 Internal Server Error\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
function () use (&$caught): void {
|
||||
try {
|
||||
(new FluxClient)->applyFirewallRule('100.64.0.10', [
|
||||
'id' => 'rule-1',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 80,
|
||||
]);
|
||||
} catch (RuntimeException $exception) {
|
||||
$caught = $exception;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect($caught)->toBeInstanceOf(RuntimeException::class)
|
||||
->not->toBeInstanceOf(UnsupportedCooldVerb::class)
|
||||
->and($caught->getMessage())->toBe('apply firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('marks caddy ingress running with a warning when the node coold lacks firewall support', function () {
|
||||
Event::fake();
|
||||
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
|
||||
$server = Server::query()->create([
|
||||
'uuid' => 'cooldverb-ingress-server-uuid',
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'ingress-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'capabilities' => ['ingress'],
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->andReturn('Caddy ingress applied.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$result = StartCaddyIngress::run($server);
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($result)->toBe('Caddy ingress applied.')
|
||||
->and($server->ingress_type)->toBe('caddy')
|
||||
->and($server->ingress_status)->toBe('running')
|
||||
->and($server->last_status_check)->toBe('flux')
|
||||
->and($server->last_status_output)->toContain('does not support firewall.allow');
|
||||
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
});
|
||||
|
||||
it('still surfaces supported-but-failed firewall errors when starting caddy ingress', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->andReturn('Caddy ingress applied.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(fn () => StartCaddyIngress::run($server))
|
||||
->toThrow(RuntimeException::class, 'apply firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('stops caddy ingress even when the node coold lacks firewall support', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
|
||||
});
|
||||
|
||||
it('skips resource connection firewall sync rules when the node coold lacks firewall support', function () {
|
||||
$sync = new ConnectionFirewallSync;
|
||||
|
||||
$oldRule = [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
|
||||
'hostId' => '100.64.0.11',
|
||||
'rule' => [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => 5432,
|
||||
],
|
||||
];
|
||||
$newRule = [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
|
||||
'hostId' => '100.64.0.10',
|
||||
'rule' => [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-redis',
|
||||
'proto' => 'tcp',
|
||||
'port' => 6379,
|
||||
],
|
||||
];
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $oldRule['id'])
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $newRule['rule'])
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
|
||||
|
||||
$sync->sync($fluxClient, collect([$oldRule]), collect([$newRule]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
function withCooldVerbContractFluxSocket(string $response, Closure $callback): void
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$connection = stream_socket_accept($socketServer, 5);
|
||||
|
||||
if ($connection !== false) {
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
fwrite($connection, $response);
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
Config::set('flux.unix_socket_path', $socketPath);
|
||||
Config::set('flux.connection_timeout_seconds', 1.0);
|
||||
Config::set('flux.dispatch_timeout_seconds', 1.0);
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
pcntl_waitpid($pid, $status);
|
||||
@unlink($socketPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { resolveCanvasNodeLayout, resolveCanvasNodePosition } from '../../../../resources/js/v5/lib/canvas-collision.ts';
|
||||
|
||||
test('moves a dragged canvas node to the closest non-overlapping side with a gap', () => {
|
||||
const settledPosition = resolveCanvasNodePosition(
|
||||
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
|
||||
[{ id: 'existing', x: 0, y: 0, width: 320, height: 136 }],
|
||||
16,
|
||||
);
|
||||
|
||||
assert.deepEqual(settledPosition, { x: 110, y: 152 });
|
||||
});
|
||||
|
||||
test('keeps moving until the closest side is clear', () => {
|
||||
const settledPosition = resolveCanvasNodePosition(
|
||||
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
|
||||
[
|
||||
{ id: 'left-blocker', x: 0, y: 0, width: 320, height: 136 },
|
||||
{ id: 'bottom-blocker', x: 110, y: 152, width: 320, height: 136 },
|
||||
{ id: 'top-blocker', x: 110, y: -152, width: 320, height: 136 },
|
||||
],
|
||||
16,
|
||||
);
|
||||
|
||||
assert.deepEqual(settledPosition, { x: -336, y: 10 });
|
||||
});
|
||||
|
||||
test('ignores the dragged node when comparing canvas collisions', () => {
|
||||
const settledPosition = resolveCanvasNodePosition(
|
||||
{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 },
|
||||
[{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 }],
|
||||
16,
|
||||
);
|
||||
|
||||
assert.deepEqual(settledPosition, { x: 24, y: 32 });
|
||||
});
|
||||
|
||||
|
||||
test('spreads an overlapping canvas layout in order', () => {
|
||||
const settledNodes = resolveCanvasNodeLayout(
|
||||
[
|
||||
{ id: 'first', x: 0, y: 0, width: 320, height: 136 },
|
||||
{ id: 'second', x: 110, y: 10, width: 320, height: 136 },
|
||||
],
|
||||
16,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
settledNodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
|
||||
[
|
||||
{ id: 'first', x: 0, y: 0 },
|
||||
{ id: 'second', x: 110, y: 152 },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Application\DeployNginxApplication;
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('builds an nginx container spec for the coold mesh runtime', function () {
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'creating',
|
||||
]);
|
||||
|
||||
$action = new DeployNginxApplication(Mockery::mock(FluxClient::class));
|
||||
$method = new ReflectionMethod($action, 'containerSpec');
|
||||
$method->setAccessible(true);
|
||||
$spec = $method->invoke($action, $application);
|
||||
|
||||
expect($spec)
|
||||
->toMatchArray([
|
||||
'name' => 'coolify-v5-nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'networks' => ['coolify-default-mesh'],
|
||||
'network_aliases' => ['coolify-v5-nginx-test'],
|
||||
'dns_search' => ['default.coolify.internal'],
|
||||
'restart_policy' => 'unless-stopped',
|
||||
])
|
||||
->not->toHaveKey('command')
|
||||
->not->toHaveKey('privileged');
|
||||
});
|
||||
|
||||
it('stops and removes the created container when starting it fails', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient
|
||||
->shouldReceive('startContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123')
|
||||
->andThrow(new RuntimeException('podman start exited with status 125'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123')
|
||||
->andReturn('Container stopped.');
|
||||
$fluxClient
|
||||
->shouldReceive('removeContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123', true)
|
||||
->andReturn('Container removed.');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('podman start exited with status 125')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('does not clean up any container when creating the container fails', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient
|
||||
->shouldReceive('createContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Flux did not return a container id.'));
|
||||
$fluxClient->shouldNotReceive('startContainer');
|
||||
$fluxClient->shouldNotReceive('stopContainer');
|
||||
$fluxClient->shouldNotReceive('removeContainer');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('Flux did not return a container id.')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the original deploy failure and logs a warning when cleanup itself fails', function () {
|
||||
Event::fake();
|
||||
Log::spy();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient
|
||||
->shouldReceive('startContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('original deploy failure'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('stop failed'));
|
||||
$fluxClient
|
||||
->shouldReceive('removeContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('remove failed'));
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('original deploy failure')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Could not stop the container created by a failed v5 deploy.', Mockery::type('array'))
|
||||
->once();
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Could not remove the container created by a failed v5 deploy.', Mockery::type('array'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('cleans up the container when it does not stay running after start', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
|
||||
$fluxClient
|
||||
->shouldReceive('inspectContainer')
|
||||
->once()
|
||||
->andReturn(['State' => ['Running' => false]]);
|
||||
$fluxClient
|
||||
->shouldReceive('stopContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123')
|
||||
->andReturn('Container stopped.');
|
||||
$fluxClient
|
||||
->shouldReceive('removeContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123', true)
|
||||
->andReturn('Container removed.');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('Container did not stay running.')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('persists the container id only for a successful deploy', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
|
||||
$fluxClient
|
||||
->shouldReceive('inspectContainer')
|
||||
->once()
|
||||
->andReturn(['State' => ['Running' => true]]);
|
||||
$fluxClient->shouldNotReceive('stopContainer');
|
||||
$fluxClient->shouldNotReceive('removeContainer');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('running')
|
||||
->and($result->runtime_container_id)->toBe('container-123');
|
||||
});
|
||||
|
||||
it('persists the runtime container id immediately after create, before start', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
|
||||
$persistedBeforeStart = null;
|
||||
$fluxClient
|
||||
->shouldReceive('startContainer')
|
||||
->once()
|
||||
->andReturnUsing(function () use ($application, &$persistedBeforeStart) {
|
||||
// Read the row straight from the database to prove the id is durable
|
||||
// before start runs, not just held in the in-memory model.
|
||||
$persistedBeforeStart = Application::query()->find($application->getKey())?->runtime_container_id;
|
||||
|
||||
throw new RuntimeException('podman start exited with status 125');
|
||||
});
|
||||
$fluxClient->shouldReceive('stopContainer')->once()->andReturn('Container stopped.');
|
||||
$fluxClient->shouldReceive('removeContainer')->once()->andReturn('Container removed.');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($persistedBeforeStart)->toBe('container-123')
|
||||
->and($result->status)->toBe('failed')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
function createNginxDeploymentApplication(): Application
|
||||
{
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
|
||||
$server = Server::query()->forceCreate([
|
||||
'uuid' => 'nginx-deployment-server-uuid',
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'node-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
return Application::query()->forceCreate([
|
||||
'uuid' => 'nginx-deployment-application-uuid',
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'status' => 'creating',
|
||||
'mesh_namespace' => 'default',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Policies\V5\ApplicationPolicy;
|
||||
use App\Policies\V5\ClusterPolicy;
|
||||
use App\Policies\V5\ResourceConnectionPolicy;
|
||||
use App\Policies\V5\ServerPolicy;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* Build a user whose team membership carries the given role, so the policies'
|
||||
* $user->isAdminOfTeam() role gate can be exercised without touching the DB.
|
||||
*/
|
||||
function v5PolicyUser(string $role = 'owner', int $teamId = 10): User
|
||||
{
|
||||
$user = (new User)->forceFill(['id' => 1]);
|
||||
$team = (new Team)->forceFill(['id' => $teamId]);
|
||||
$pivot = new Pivot;
|
||||
$pivot->role = $role;
|
||||
$team->setRelation('pivot', $pivot);
|
||||
$user->setRelation('teams', collect([$team]));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function v5PolicyMemberUser(int $teamId = 10): User
|
||||
{
|
||||
return v5PolicyUser('member', $teamId);
|
||||
}
|
||||
|
||||
function v5PolicyTeam(int $id = 10): Team
|
||||
{
|
||||
return (new Team)->forceFill(['id' => $id]);
|
||||
}
|
||||
|
||||
it('allows adding a server to a cluster of the current team', function () {
|
||||
$team = v5PolicyTeam();
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
|
||||
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $cluster);
|
||||
|
||||
expect($response->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies adding a server to another teams cluster as forbidden instead of not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
|
||||
|
||||
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $foreignCluster);
|
||||
|
||||
// A plain deny carries no status override, so the framework renders the
|
||||
// historical 403 instead of a 404.
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
});
|
||||
|
||||
it('denies server update and delete outside the current team as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
|
||||
$foreignServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'cluster_id' => 5]);
|
||||
|
||||
$policy = new ServerPolicy;
|
||||
|
||||
$update = $policy->update(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
|
||||
$delete = $policy->delete(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
|
||||
|
||||
expect($update->denied())->toBeTrue()
|
||||
->and($update->status())->toBe(404)
|
||||
->and($delete->denied())->toBeTrue()
|
||||
->and($delete->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('denies server update for a server outside the addressed cluster as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$serverInOtherCluster = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 6]);
|
||||
|
||||
$response = (new ServerPolicy)->update(v5PolicyUser(), $serverInOtherCluster, $team, $cluster);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('allows moving the caddy ingress canvas card only for ingress servers of the current team', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ServerPolicy;
|
||||
|
||||
$ingressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => true]);
|
||||
|
||||
expect($policy->updateCanvasPosition(v5PolicyUser(), $ingressServer, $team)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies moving the canvas card of a non-ingress server as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$nonIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => false]);
|
||||
|
||||
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $nonIngressServer, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('denies moving another teams ingress canvas card as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$foreignIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'is_ingress' => true]);
|
||||
|
||||
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $foreignIngressServer, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('scopes cluster viewing and deletion to the current team with not-found denials', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ClusterPolicy;
|
||||
|
||||
$ownCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$foreignCluster = (new Cluster)->forceFill(['id' => 6, 'team_id' => 99]);
|
||||
|
||||
expect($policy->view(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue()
|
||||
->and($policy->delete(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue();
|
||||
|
||||
$view = $policy->view(v5PolicyUser(), $foreignCluster, $team);
|
||||
$delete = $policy->delete(v5PolicyUser(), $foreignCluster, $team);
|
||||
|
||||
expect($view->denied())->toBeTrue()
|
||||
->and($view->status())->toBe(404)
|
||||
->and($delete->denied())->toBeTrue()
|
||||
->and($delete->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('scopes application mutations to the current team with not-found denials', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ApplicationPolicy;
|
||||
|
||||
$ownApplication = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$foreignApplication = (new V5Application)->forceFill(['id' => 4, 'team_id' => 99]);
|
||||
|
||||
expect($policy->update(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
|
||||
->and($policy->updateIngress(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
|
||||
->and($policy->delete(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue();
|
||||
|
||||
foreach (['update', 'updateIngress', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}(v5PolicyUser(), $foreignApplication, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes resource connection mutations to the current team with not-found denials', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ResourceConnectionPolicy;
|
||||
|
||||
$ownConnection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$foreignConnection = (new ResourceConnection)->forceFill(['id' => 4, 'team_id' => 99]);
|
||||
|
||||
expect($policy->update(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue()
|
||||
->and($policy->delete(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue();
|
||||
|
||||
foreach (['update', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}(v5PolicyUser(), $foreignConnection, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies server mutations for a member of the current team as forbidden not not-found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5, 'is_ingress' => true]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ServerPolicy;
|
||||
|
||||
expect($policy->create($member, $team, $cluster)->denied())->toBeTrue()
|
||||
->and($policy->create($member, $team, $cluster)->status())->toBeNull();
|
||||
|
||||
foreach (['update', 'delete', 'check', 'bootstrap'] as $ability) {
|
||||
$response = $policy->{$ability}($member, $server, $team, $cluster);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
}
|
||||
|
||||
expect($policy->updateCanvasPosition($member, $server, $team)->denied())->toBeTrue()
|
||||
->and($policy->updateCanvasPosition($member, $server, $team)->status())->toBeNull();
|
||||
});
|
||||
|
||||
it('still lets a member read server diagnostics of the current team', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5]);
|
||||
|
||||
expect((new ServerPolicy)->viewDiagnostics(v5PolicyMemberUser(), $server, $team, $cluster)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies cluster create and delete for a member of the current team as forbidden', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ClusterPolicy;
|
||||
|
||||
expect($policy->create($member, $team)->denied())->toBeTrue()
|
||||
->and($policy->create($member, $team)->status())->toBeNull()
|
||||
->and($policy->delete($member, $cluster, $team)->denied())->toBeTrue()
|
||||
->and($policy->delete($member, $cluster, $team)->status())->toBeNull();
|
||||
|
||||
// Members may still read.
|
||||
expect($policy->view($member, $cluster, $team)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows an admin to create a cluster in the current team', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
|
||||
expect((new ClusterPolicy)->create(v5PolicyUser('admin'), $team)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies application mutations for a member of the current team as forbidden', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$application = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ApplicationPolicy;
|
||||
|
||||
expect($policy->create($member, $team)->denied())->toBeTrue();
|
||||
|
||||
foreach (['update', 'updateIngress', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}($member, $application, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('denies resource connection mutations for a member of the current team as forbidden', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$connection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ResourceConnectionPolicy;
|
||||
|
||||
expect($policy->create($member, $team)->denied())->toBeTrue();
|
||||
|
||||
foreach (['update', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}($member, $connection, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows admins to create applications and resource connections', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$admin = v5PolicyUser('admin');
|
||||
|
||||
expect((new ApplicationPolicy)->create($admin, $team)->allowed())->toBeTrue()
|
||||
->and((new ResourceConnectionPolicy)->create($admin, $team)->allowed())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
it('includes v5 applications in the v4 resource index and links them to v5', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('V5Application')
|
||||
->toContain("route('v5.dashboard'")
|
||||
->toContain("? 'v5' : 'v4'")
|
||||
->and($view)
|
||||
->toContain("item.version === 'v5'")
|
||||
->toContain('V5');
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Jobs\V5DeployApplicationJob;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('locks v5 deploy jobs per application for the job timeout plus a margin', function () {
|
||||
$job = new V5DeployApplicationJob(42);
|
||||
|
||||
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
|
||||
->and($job->uniqueId())->toBe('42')
|
||||
->and($job->uniqueFor)->toBe($job->timeout + 60);
|
||||
});
|
||||
|
||||
it('locks v5 bootstrap jobs per server aligned with the controller bootstrap claim window', function () {
|
||||
$job = new V5BootstrapServerJob(7, 13);
|
||||
|
||||
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
|
||||
->and($job->uniqueId())->toBe('13')
|
||||
->and($job->uniqueFor)->toBe(V5BootstrapServerJob::TIMEOUT_SECONDS + 300);
|
||||
});
|
||||
|
||||
it('does not queue a second v5 deploy job while one is pending for the same application', function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Queue::fake();
|
||||
|
||||
V5DeployApplicationJob::dispatch(101);
|
||||
V5DeployApplicationJob::dispatch(101);
|
||||
V5DeployApplicationJob::dispatch(202);
|
||||
|
||||
Queue::assertPushed(V5DeployApplicationJob::class, 2);
|
||||
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 101);
|
||||
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 202);
|
||||
});
|
||||
|
||||
it('does not queue a second v5 bootstrap job while one is pending for the same server', function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Queue::fake();
|
||||
|
||||
V5BootstrapServerJob::dispatch(1, 55);
|
||||
V5BootstrapServerJob::dispatch(2, 55);
|
||||
V5BootstrapServerJob::dispatch(1, 66);
|
||||
|
||||
Queue::assertPushed(V5BootstrapServerJob::class, 2);
|
||||
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 55);
|
||||
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 66);
|
||||
});
|
||||
|
||||
it('queues v5 canvas and cluster broadcasts after commit instead of pushing on the request thread', function () {
|
||||
$canvasEvent = new V5CanvasResourceUpdated(1, 2);
|
||||
$clusterEvent = new V5ClusterUpdated(1, 2);
|
||||
|
||||
expect($canvasEvent)->toBeInstanceOf(ShouldBroadcast::class)
|
||||
->not->toBeInstanceOf(ShouldBroadcastNow::class)
|
||||
->and($canvasEvent->afterCommit)->toBeTrue()
|
||||
->and($clusterEvent)->toBeInstanceOf(ShouldBroadcast::class)
|
||||
->not->toBeInstanceOf(ShouldBroadcastNow::class)
|
||||
->and($clusterEvent->afterCommit)->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the manual v5 realtime test ping broadcasting immediately', function () {
|
||||
expect(new V5RealtimeTestEvent(1, 'ping'))->toBeInstanceOf(ShouldBroadcastNow::class);
|
||||
});
|
||||
@@ -50,6 +50,11 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
|
||||
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'newline before dot' => ["evil\n.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'newline inside label' => ["evil\ncom", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'carriage return' => ["evil\r.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'tab character' => ["evil\t.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'caddy block injection' => ["evil.com {\n} http://x", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
]);
|
||||
|
||||
it('accepts empty hostname', function () {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Rules\ValidServerIp;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('accepts private and reserved IP addresses like the next branch', function (string $ip) {
|
||||
$rule = new ValidServerIp;
|
||||
$failCalled = false;
|
||||
|
||||
$rule->validate('ip', $ip, function () use (&$failCalled): void {
|
||||
$failCalled = true;
|
||||
});
|
||||
|
||||
expect($failCalled)->toBeFalse();
|
||||
})->with([
|
||||
'private IPv4' => '192.168.1.10',
|
||||
'loopback IPv4' => '127.0.0.1',
|
||||
'link-local IPv4' => '169.254.1.10',
|
||||
'loopback IPv6' => '::1',
|
||||
'unique local IPv6' => 'fd00::1',
|
||||
]);
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -10,6 +12,52 @@ beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('maps final Vultr API error responses', function (int $status, string $message) {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances' => Http::response(['error' => $message], $status),
|
||||
]);
|
||||
|
||||
$exception = null;
|
||||
|
||||
try {
|
||||
(new VultrService('fake-token'))->createInstance([]);
|
||||
} catch (Throwable $caught) {
|
||||
$exception = $caught;
|
||||
}
|
||||
|
||||
expect($exception)->toBeInstanceOf(Exception::class)
|
||||
->and($exception)->not->toBeInstanceOf(RequestException::class)
|
||||
->and($exception->getMessage())->toBe('Vultr API error: '.$message)
|
||||
->and($exception->getCode())->toBe($status);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
})->with([
|
||||
'unauthorized' => [401, 'Unauthorized'],
|
||||
'unprocessable entity' => [422, 'Invalid instance parameters'],
|
||||
]);
|
||||
|
||||
it('maps a final Vultr rate-limit response with Retry-After', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances' => Http::sequence()
|
||||
->push(['error' => 'Temporary provider error'], 500)
|
||||
->push(['error' => 'Temporary provider error'], 500)
|
||||
->push(['error' => 'Too many requests'], 429, ['Retry-After' => '30']),
|
||||
]);
|
||||
|
||||
try {
|
||||
(new VultrService('fake-token'))->createInstance([]);
|
||||
} catch (RateLimitException $exception) {
|
||||
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
|
||||
->and($exception->retryAfter)->toBe(30);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
test()->fail('Expected a RateLimitException to be thrown.');
|
||||
});
|
||||
|
||||
it('gets instances from Vultr API', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances*' => Http::response([
|
||||
|
||||
Reference in new issue
Block a user