Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

+14
View File
@@ -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');
});
+27
View File
@@ -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('');
});
+10 -5
View File
@@ -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');
});
+72
View File
@@ -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');
});
+35
View File
@@ -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://');
}
});
+33
View File
@@ -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'],
])],
]);
+22
View File
@@ -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',
+1
View File
@@ -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'");
+48
View File
@@ -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([
+48
View File
@@ -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();
});
+93
View File
@@ -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');
});
+6 -2
View File
@@ -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) {
+51
View File
@@ -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");
});
+52
View File
@@ -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();
});
+27
View File
@@ -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);
});
+86
View File
@@ -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)"');
});
+20
View File
@@ -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');
});
+9
View File
@@ -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');
}
});
+21 -1
View File
@@ -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;
+192
View File
@@ -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)');
});
+95
View File
@@ -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');
});
+136 -1
View File
@@ -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();
});
+28
View File
@@ -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=');
});
+28
View File
@@ -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=");
});
+23
View File
@@ -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);
+151
View File
@@ -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');
});
+100
View File
@@ -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);
}
}
+314
View File
@@ -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',
]);
}
+265
View File
@@ -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');
});
+73
View File
@@ -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);
});
+5
View File
@@ -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 () {
+23
View File
@@ -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',
]);
+48
View File
@@ -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([