Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber

This commit is contained in:
Andras Bacsai
2026-05-11 17:34:48 +02:00
54 changed files with 3906 additions and 576 deletions
+345
View File
@@ -0,0 +1,345 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'railpack-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function railpackApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function makeRailpackApp(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'railpack',
], $overrides));
}
describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () {
test('rejects unsupported build_pack at controller layer', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'totally-bogus',
]);
$response->assertStatus(422);
});
test('switching from dockerfile to railpack clears dockerfile fields', function () {
$app = makeRailpackApp([
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:20',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'railpack',
]);
$response->assertOk();
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->dockerfile)->toBeNull();
// NOTE: dockerfile_location is normalized to '/Dockerfile' by the model
// mutator when set to null, so we cannot assert it becomes null here.
expect($app->dockerfile_target_build)->toBeNull();
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
});
test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () {
$app = makeRailpackApp([
'build_pack' => 'dockercompose',
'docker_compose_domains' => '{"app": "example.com"}',
'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx",
]);
$app->environment_variables()->createMany([
['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false],
['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false],
['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false],
]);
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'railpack',
]);
$response->assertOk();
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->docker_compose_domains)->toBeNull();
expect($app->docker_compose_raw)->toBeNull();
expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('install/build/start commands persist for railpack apps', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$response->assertOk();
$app->refresh();
expect($app->install_command)->toBe('npm ci');
expect($app->build_command)->toBe('npm run build');
expect($app->start_command)->toBe('node server.js');
});
});
describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () {
test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
]);
$response->assertCreated();
$app->refresh();
expect($app->railpack_environment_variables)->toHaveCount(1);
expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION');
expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0);
});
test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => true,
'is_buildtime' => false,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$runtime = $app->runtime_environment_variables;
expect($runtime->pluck('key')->all())->toBe(['APP_ENV']);
expect($app->railpack_environment_variables)->toHaveCount(0);
});
test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_BUILD_CMD',
'value' => 'npm run build',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => true,
])->assertCreated();
$app->refresh();
expect($app->railpack_environment_variables_preview)->toHaveCount(1);
expect($app->railpack_environment_variables)->toHaveCount(0);
});
test('buildtime-only env has is_buildtime=true and is_runtime=false', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'API_KEY',
'value' => 'sekret',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'API_KEY')->first();
expect($env)->not->toBeNull();
expect((bool) $env->is_buildtime)->toBeTrue();
expect((bool) $env->is_runtime)->toBeFalse();
// Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var)
// but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone.
expect($env->resourceable_id)->toBe($app->id);
});
test('runtime-only env has is_runtime=true and is_buildtime=false', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'LOG_LEVEL',
'value' => 'debug',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first();
expect((bool) $env->is_buildtime)->toBeFalse();
expect((bool) $env->is_runtime)->toBeTrue();
});
test('railpack build variables collection includes only is_buildtime=true entries', function () {
// Sanity check the underlying query used by the deploy job: railpack_build_variables()
// pulls $application->environment_variables()->where('is_buildtime', true)->get()
// (see ApplicationDeploymentJob::railpack_build_variables).
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'BUILD_ARG',
'value' => 'in-build',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RUNTIME_ARG',
'value' => 'in-runtime',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all();
expect($buildtime)->toContain('BUILD_ARG');
expect($buildtime)->not->toContain('RUNTIME_ARG');
});
test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () {
// Documents generate_coolify_env_variables() override behavior:
// it skips generation when application->environment_variables already has the key.
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'COOLIFY_FQDN',
'value' => 'overridden.example.com',
'is_buildtime' => true,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first();
expect($env)->not->toBeNull();
expect($env->value)->toBe('overridden.example.com');
// Confirm the model relation used by override-skip logic finds it
expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse();
});
test('is_literal flag persists on create', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_LITERAL_FLAG',
'value' => '$NOT_INTERPOLATED',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
'is_literal' => true,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first();
expect((bool) $env->is_literal)->toBeTrue();
});
test('PATCH env updates buildtime/runtime flags', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'TOGGLE_VAR',
'value' => 'v1',
'is_buildtime' => true,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'TOGGLE_VAR',
'value' => 'v2',
'is_buildtime' => false,
'is_runtime' => true,
'is_multiline' => false,
'is_shown_once' => false,
])->assertStatus(201);
$app->refresh();
$env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first();
expect($env->value)->toBe('v2');
expect((bool) $env->is_buildtime)->toBeFalse();
expect((bool) $env->is_runtime)->toBeTrue();
});
});
@@ -78,26 +78,29 @@ describe('Application Model Buildpack Cleanup', function () {
// Add environment variables that should be deleted
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_FQDN_APP',
'value' => 'app.example.com',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_URL_APP',
'value' => 'http://app.example.com',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'should_remain',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
@@ -117,6 +120,87 @@ describe('Application Model Buildpack Cleanup', function () {
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
$application->build_pack = 'railpack';
$application->save();
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
});
test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockercompose',
'docker_compose_domains' => '{"app": "example.com"}',
'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx',
]);
// Add environment variables that should be deleted
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_FQDN_APP',
'value' => 'app.example.com',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_URL_APP',
'value' => 'http://app.example.com',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'should_remain',
'is_buildtime' => false,
'is_preview' => false,
]);
$application->build_pack = 'railpack';
$application->save();
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->docker_compose_domains)->toBeNull();
expect($application->docker_compose_raw)->toBeNull();
// Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted
expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
// Verify regular variables remain
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('model does not clear dockerfile fields when switching to dockerfile', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
@@ -0,0 +1,388 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
uses(RefreshDatabase::class);
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public ?string $writtenDockerfile = null;
public function __construct() {}
public function execute_remote_command(...$commands)
{
$this->recordedCommands[] = $commands;
foreach ($commands as $command) {
$commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command;
if (! is_string($commandString)) {
continue;
}
if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) {
$this->writtenDockerfile = base64_decode($matches[1]) ?: null;
}
}
}
}
function makeDeploymentControlVarFixture(array $applicationAttributes = []): array
{
$team = Team::create([
'name' => 'Control Var Team',
'description' => 'Team for deployment control var tests.',
'personal_team' => false,
'show_boarding' => false,
]);
$project = Project::create([
'name' => 'Control Var Project',
'team_id' => $team->id,
]);
$environment = Environment::where('project_id', $project->id)->firstOrFail();
$server = Server::factory()->create([
'team_id' => $team->id,
]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockerfile',
...$applicationAttributes,
]);
$application->settings()->update([
'inject_build_args_to_dockerfile' => true,
'include_source_commit_in_build' => false,
'is_env_sorting_enabled' => false,
]);
return [$application->fresh(), $server];
}
function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable
{
return EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => true,
'is_multiline' => false,
'is_literal' => false,
...$attributes,
]);
}
function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array
{
$job = new TestableControlVarFilteringDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
$queue->shouldReceive('addLogEntry')->andReturnNull();
$properties = [
'application' => $application->fresh(),
'application_deployment_queue' => $queue,
'build_pack' => $application->build_pack,
'mainServer' => $server,
'pull_request_id' => 0,
'commit' => 'HEAD',
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'dockerfile_location' => '/Dockerfile',
'container_name' => 'control-var-app',
'coolify_variables' => null,
'dockerSecretsSupported' => false,
];
$mergedProperties = array_merge($properties, $overrides);
$mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []);
if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) {
$mergedProperties['preview'] = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => $mergedProperties['pull_request_id'],
'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'],
'fqdn' => 'https://preview.example.com',
]);
}
foreach ($mergedProperties as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection];
}
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invoke($job);
}
function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
{
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
return $reflectionProperty->getValue($job);
}
it('filters buildpack control vars from generic build args', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
/** @var Collection $envArgs */
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
expect($envArgs->get('APP_ENV'))->toBe('production');
expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse();
});
it('filters buildpack control vars from preview build-time env files', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_preview' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_preview' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_preview' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 42,
]);
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue();
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
});
it('filters buildpack control vars from preview runtime env fallback', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_NAME',
'value' => 'coolify',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_FLAG',
'value' => 'enabled',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
$application->environment_variables_preview()
->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION'])
->delete();
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 99,
]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
});
it('filters buildpack control vars from dockerfile arg injection', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'saved_outputs' => [
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
],
]);
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
});
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
'fqdn' => 'https://railpack.example.com',
'install_command' => 'pnpm install --frozen-lockfile',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RUNTIME_ONLY',
'value' => 'runtime',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'main',
]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect($variables->get('APP_ENV'))->toBe('production');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
});
it('builds preview railpack variables without leaking stale nixpacks vars', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
'fqdn' => 'https://railpack.example.com',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_BUILD_FLAG',
'value' => 'enabled',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_RUNTIME_ONLY',
'value' => 'runtime',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'feature/railpack',
'pull_request_id' => 123,
]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
});
@@ -0,0 +1,86 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('existing application buildpack selector lists nixpacks before railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSeeInOrder([
'<option value="nixpacks">Nixpacks</option>',
'<option value="railpack">Railpack (Beta)</option>',
], false);
});
test('existing application shows railpack beta badge in build helper copy', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'railpack',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('Railpack')
->assertSee('Beta');
});
+150
View File
@@ -0,0 +1,150 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('Application Railpack Support', function () {
beforeEach(function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
test('could_set_build_commands returns true for railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
expect($application->could_set_build_commands())->toBeTrue();
});
test('could_set_build_commands returns true for nixpacks', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'nixpacks',
]);
expect($application->could_set_build_commands())->toBeTrue();
});
test('could_set_build_commands returns false for dockerfile', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
expect($application->could_set_build_commands())->toBeFalse();
});
test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'value',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_preview' => false,
]);
$railpackVars = $application->railpack_environment_variables;
expect($railpackVars)->toHaveCount(1);
expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION');
});
test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'APP_ENV',
'value' => 'production',
'is_buildtime' => false,
'is_preview' => false,
]);
$runtimeVars = $application->runtime_environment_variables;
expect($runtimeVars)->toHaveCount(1);
expect($runtimeVars->first()->key)->toBe('APP_ENV');
});
test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_BUILD_CMD',
'value' => 'npm run build',
'is_buildtime' => true,
'is_preview' => true,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'value',
'is_buildtime' => false,
'is_preview' => true,
]);
$previewVars = $application->railpack_environment_variables_preview;
expect($previewVars)->toHaveCount(1);
expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD');
});
});
+51
View File
@@ -0,0 +1,51 @@
<?php
use App\Models\Application;
use Database\Seeders\ApplicationSeeder;
use Database\Seeders\GithubAppSeeder;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ServerSeeder;
use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('seeds a railpack nodejs fastify example alongside the existing nixpacks example', function () {
$this->seed([
UserSeeder::class,
TeamSeeder::class,
PrivateKeySeeder::class,
ServerSeeder::class,
ProjectSeeder::class,
StandaloneDockerSeeder::class,
GithubAppSeeder::class,
ApplicationSeeder::class,
]);
$nixpacksExample = Application::where('uuid', 'nodejs')->first();
$railpackExample = Application::where('uuid', 'railpack-nodejs')->first();
expect($nixpacksExample)
->not->toBeNull()
->and($nixpacksExample->name)->toBe('NodeJS Fastify Example')
->and($nixpacksExample->build_pack)->toBe('nixpacks')
->and($nixpacksExample->base_directory)->toBe('/nodejs')
->and($nixpacksExample->ports_exposes)->toBe('3000');
expect($railpackExample)
->not->toBeNull()
->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example')
->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io')
->and($railpackExample->repository_project_id)->toBe(603035348)
->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples')
->and($railpackExample->git_branch)->toBe('v4.x')
->and($railpackExample->base_directory)->toBe('/nodejs')
->and($railpackExample->build_pack)->toBe('railpack')
->and($railpackExample->ports_exposes)->toBe('3000')
->and($railpackExample->environment_id)->toBe(1)
->and($railpackExample->destination_id)->toBe(0)
->and($railpackExample->source_id)->toBe(1);
});
@@ -111,6 +111,29 @@ describe('Buildpack Switching Cleanup', function () {
expect($application->dockerfile)->toBeNull();
});
test('clears dockerfile fields when switching from dockerfile to railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('buildPack', 'railpack')
->call('updatedBuildPack');
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
});
test('clears dockerfile fields when switching from dockerfile to dockercompose', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
@@ -0,0 +1,18 @@
<?php
it('positions the deployments indicator from the sidebar collapsed state', function () {
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
$layoutView = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($indicatorView)
->toContain('transition-[left] duration-200')
->toContain(":class=\"collapsed ? 'lg:left-16' : 'lg:left-56'\"")
->not->toContain('fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4');
expect($layoutView)
->toContain('<div x-data="{')
->toContain('<livewire:deployments-indicator />');
expect(strpos($layoutView, '<div x-data="{'))
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
});
@@ -0,0 +1,22 @@
<?php
use App\Http\Controllers\Api\ServicesController;
use Illuminate\Support\Facades\Route;
test('deprecated docker compose application endpoint is not registered', function () {
$routes = collect(Route::getRoutes()->getRoutes())
->filter(fn ($route) => in_array('POST', $route->methods(), true))
->filter(fn ($route) => $route->uri() === 'api/v1/applications/dockercompose');
expect($routes)->toBeEmpty();
$this->postJson('/api/v1/applications/dockercompose')->assertNotFound();
});
test('custom docker compose services endpoint remains registered', function () {
$route = collect(Route::getRoutes()->getRoutes())
->first(fn ($route) => in_array('POST', $route->methods(), true) && $route->uri() === 'api/v1/services');
expect($route)->not->toBeNull()
->and($route->getActionName())->toBe(ServicesController::class.'@create_service');
});
@@ -0,0 +1,141 @@
<?php
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Database\Seeders\DevelopmentRailpackExamplesSeeder;
use Database\Seeders\GithubAppSeeder;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ServerSeeder;
use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function seedRailpackExamplePrerequisites(): void
{
test()->seed([
UserSeeder::class,
TeamSeeder::class,
PrivateKeySeeder::class,
ServerSeeder::class,
ProjectSeeder::class,
StandaloneDockerSeeder::class,
GithubAppSeeder::class,
]);
}
it('can seed the railpack examples directly on a clean development database', function () {
config()->set('app.env', 'local');
$this->seed(DevelopmentRailpackExamplesSeeder::class);
expect(Team::query()->find(0))->not->toBeNull();
expect(PrivateKey::query()->find(1))->not->toBeNull();
expect(Server::query()->find(0))->not->toBeNull();
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
expect(GithubApp::query()->find(0))->not->toBeNull();
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
it('seeds the railpack examples in development mode', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
expect($project)
->not->toBeNull()
->and($project->name)->toBe('Railpack Examples')
->and($project->environments)->toHaveCount(1)
->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID);
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
$nestjs = $applications->firstWhere('uuid', 'railpack-nestjs');
$angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static');
$eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static');
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
expect($nestjs)
->not->toBeNull()
->and($nestjs->base_directory)->toBe('/node/nestjs')
->and($nestjs->ports_exposes)->toBe('3000')
->and($nestjs->build_command)->toBe('npm run build')
->and($nestjs->start_command)->toBe('npm run start:prod')
->and($nestjs->settings->is_static)->toBeFalse();
expect($angularStatic)
->not->toBeNull()
->and($angularStatic->publish_directory)->toBe('/dist/static/browser')
->and($angularStatic->ports_exposes)->toBe('80')
->and($angularStatic->settings->is_static)->toBeTrue()
->and($angularStatic->settings->is_spa)->toBeTrue();
expect($eleventyStatic)
->not->toBeNull()
->and($eleventyStatic->publish_directory)->toBe('/_site')
->and($eleventyStatic->settings->is_static)->toBeTrue()
->and($eleventyStatic->settings->is_spa)->toBeFalse();
expect($pythonFlask)
->not->toBeNull()
->and($pythonFlask->ports_exposes)->toBe('5000')
->and($pythonFlask->start_command)->toBe('flask run --host=0.0.0.0 --port=5000');
expect($goGin)
->not->toBeNull()
->and($goGin->ports_exposes)->toBe('3000');
expect($rust)
->not->toBeNull()
->and($rust->ports_exposes)->toBe('8000');
});
it('skips the railpack examples outside development mode', function () {
config()->set('app.env', 'testing');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
});
it('is idempotent when run multiple times', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
expect($project)->not->toBeNull();
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
@@ -0,0 +1,39 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
use Livewire\Livewire;
it('rejects environment variable keys Docker cannot represent in the add form', function () {
Livewire::test(Add::class)
->set('key', 'BAD=KEY')
->set('value', 'value')
->call('submit')
->assertHasErrors(['key' => 'regex']);
});
it('allows Docker-compatible environment variable keys in the add form', function (string $key) {
Livewire::test(Add::class)
->set('key', $key)
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) use ($key) {
return data_get($data, 'key') === $key || data_get($data, '0.key') === $key;
});
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
]);
it('trims surrounding whitespace in environment variable keys in the add form', function () {
Livewire::test(Add::class)
->set('key', ' node.name ')
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) {
return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name';
});
});
@@ -0,0 +1,121 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('PublicGitRepository port handling for railpack', function () {
test('switching to railpack resets port to 3000 when not static', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('build_pack', 'dockerfile')
->assertSet('port', 3000)
->set('build_pack', 'railpack')
->assertSet('port', 3000);
});
test('switching to railpack preserves port when isStatic is true', function () {
$component = Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('isStatic', true)
->call('instantSave');
// After instantSave with isStatic=true, port becomes 80
$component->assertSet('port', 80);
// Switching from nixpacks to railpack should NOT clobber port back to 3000
$component->set('build_pack', 'railpack')
->assertSet('port', 80);
});
test('switching to static sets port to 80 and disables show_is_static', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('build_pack', 'static')
->assertSet('port', 80)
->assertSet('isStatic', false)
->assertSet('show_is_static', false);
});
});
describe('General view railpack helper text', function () {
beforeEach(function () {
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('railpack app shows railpack.json helper text and not nixpacks.toml', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'railpack',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('railpack.json')
->assertDontSee('nixpacks.toml');
});
test('nixpacks app shows nixpacks.toml helper text and not railpack.json', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('nixpacks.toml')
->assertDontSee('railpack.json');
});
});
@@ -0,0 +1,51 @@
<?php
use App\Livewire\Project\New\GithubPrivateRepository;
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('new application buildpack defaults', function () {
test('github app repository flow defaults to nixpacks', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertSet('build_pack', 'nixpacks');
});
test('deploy key repository flow defaults to nixpacks', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->assertSet('build_pack', 'nixpacks');
});
test('public repository flow defaults to nixpacks and lists railpack second', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->assertSet('build_pack', 'nixpacks');
});
test('public repository flow keeps railpack available after branch lookup', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('branchFound', true)
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)'])
->assertSee('Beta');
});
test('deploy key repository flow shows railpack beta label in build pack selector', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->set('current_step', 'repository')
->assertSee('Railpack (Beta)')
->assertSee('Beta');
});
});
@@ -2,10 +2,14 @@
use App\Jobs\ServerLimitCheckJob;
use App\Jobs\StripeProcessJob;
use App\Jobs\SubscriptionInvoiceFailedJob;
use App\Jobs\VerifyStripeSubscriptionStatusJob;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Internal\GeneralNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -228,3 +232,65 @@ describe('ServerLimitCheckJob dispatch is guarded by team check', function () {
Queue::assertNotPushed(ServerLimitCheckJob::class);
});
});
describe('missing subscription Stripe webhooks are ignored', function () {
test('does not send internal notifications or queue follow-up jobs', function (array $event) {
Queue::fake();
$rootTeam = Team::factory()->create(['id' => 0]);
$rootTeam->discordNotificationSettings()->update(['discord_enabled' => true]);
Notification::fake();
$job = new StripeProcessJob($event);
$job->handle();
Notification::assertNothingSent();
Notification::assertNotSentTo($rootTeam, GeneralNotification::class);
Queue::assertNotPushed(SubscriptionInvoiceFailedJob::class);
Queue::assertNotPushed(VerifyStripeSubscriptionStatusJob::class);
})->with([
'invoice paid' => [[
'type' => 'invoice.paid',
'data' => [
'object' => [
'customer' => 'cus_missing_invoice_paid',
'amount_paid' => 1000,
'subscription' => 'sub_missing_invoice_paid',
'lines' => [
'data' => [[
'plan' => ['id' => 'price_dynamic_monthly'],
]],
],
],
],
]],
'invoice payment failed' => [[
'type' => 'invoice.payment_failed',
'data' => [
'object' => [
'customer' => 'cus_missing_invoice_payment_failed',
'id' => 'in_missing_invoice_payment_failed',
'payment_intent' => null,
],
],
]],
'payment intent payment failed' => [[
'type' => 'payment_intent.payment_failed',
'data' => [
'object' => [
'customer' => 'cus_missing_payment_intent_failed',
],
],
]],
'customer subscription deleted' => [[
'type' => 'customer.subscription.deleted',
'data' => [
'object' => [
'customer' => 'cus_missing_subscription_deleted',
'id' => 'sub_missing_subscription_deleted',
],
],
]],
]);
});
@@ -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'");
});
@@ -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');
});
+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');
});