Merge remote-tracking branch 'origin/next' into fix/railpack-buildtime-env-interpolation

This commit is contained in:
Andras Bacsai committed 2026-06-29 10:20:07 +02:00
commit 9a64b2ea0a
18 files changed
+626 -196

No files matched your search

@@ -15,7 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -252,3 +252,94 @@ describe('PATCH /api/v1/applications/{uuid}/envs', function () {
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
});
});
describe('environment variable key validation for app and service APIs', function () {
test('rejects invalid service environment variable keys on create update and bulk', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
test('rejects invalid application environment variable keys on create update and bulk', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
@@ -14,7 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -344,3 +344,44 @@ describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () {
$response->assertStatus(404);
});
});
describe('environment variable key validation for database APIs', function () {
test('rejects invalid database environment variable keys on create update and bulk', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -42,6 +43,7 @@ it('can seed the railpack examples directly on a clean development database', fu
expect(Server::query()->find(0))->not->toBeNull();
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
expect(GithubApp::query()->find(0))->not->toBeNull();
expect(GitlabApp::query()->find(1))->not->toBeNull();
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
@@ -66,9 +68,10 @@ it('seeds the railpack examples in development mode', function () {
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_repository === ($examples->get($application->uuid)['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
@@ -79,6 +82,9 @@ it('seeds the railpack examples in development mode', function () {
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
$githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key');
$gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key');
$gitlabPublic = $applications->firstWhere('uuid', 'railpack-gitlab-public-example');
expect($nestjs)
->not->toBeNull()
@@ -113,6 +119,33 @@ it('seeds the railpack examples in development mode', function () {
expect($rust)
->not->toBeNull()
->and($rust->ports_exposes)->toBe('8000');
expect($githubDeployKey)
->not->toBeNull()
->and($githubDeployKey->git_repository)->toBe('git@github.com:coollabsio/coolify-examples-deploy-key.git')
->and($githubDeployKey->git_branch)->toBe('main')
->and($githubDeployKey->build_pack)->toBe('railpack')
->and($githubDeployKey->private_key_id)->toBe(1)
->and($githubDeployKey->source_type)->toBe(GithubApp::class)
->and($githubDeployKey->source_id)->toBe(0);
expect($gitlabDeployKey)
->not->toBeNull()
->and($gitlabDeployKey->git_repository)->toBe('git@gitlab.com:coollabsio/php-example.git')
->and($gitlabDeployKey->git_branch)->toBe('main')
->and($gitlabDeployKey->build_pack)->toBe('railpack')
->and($gitlabDeployKey->private_key_id)->toBe(1)
->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class)
->and($gitlabDeployKey->source_id)->toBe(1);
expect($gitlabPublic)
->not->toBeNull()
->and($gitlabPublic->git_repository)->toBe('https://gitlab.com/andrasbacsai/coolify-examples.git')
->and($gitlabPublic->base_directory)->toBe('/astro/static')
->and($gitlabPublic->publish_directory)->toBe('/dist')
->and($gitlabPublic->build_pack)->toBe('railpack')
->and($gitlabPublic->source_type)->toBe(GitlabApp::class)
->and($gitlabPublic->settings->is_static)->toBeTrue();
});
it('skips the railpack examples outside development mode', function () {
@@ -9,8 +9,8 @@ test('generateDockerBuildArgs returns only keys without values', function () {
$buildArgs = generateDockerBuildArgs($variables);
// Docker gets values from the environment, so only keys should be in build args
expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
expect($buildArgs->first())->toBe("--build-arg 'SSH_PRIVATE_KEY'");
expect($buildArgs->last())->toBe("--build-arg 'REGULAR_VAR'");
});
test('generateDockerBuildArgs works with collection of objects', function () {
@@ -22,8 +22,8 @@ test('generateDockerBuildArgs works with collection of objects', function () {
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
expect($buildArgs->values()->toArray())->toBe([
'--build-arg VAR1',
'--build-arg VAR2',
"--build-arg 'VAR1'",
"--build-arg 'VAR2'",
]);
});
@@ -38,7 +38,7 @@ test('generateDockerBuildArgs collection can be imploded into valid command stri
// The collection must be imploded to a string for command interpolation
// This was the bug: Collection was interpolated as JSON instead of a space-separated string
$argsString = $buildArgs->implode(' ');
expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
expect($argsString)->toBe("--build-arg 'COOLIFY_URL' --build-arg 'COOLIFY_BRANCH'");
// Verify it does NOT produce JSON when cast to string
expect($argsString)->not->toContain('{');
@@ -53,7 +53,7 @@ test('generateDockerBuildArgs handles variables without is_multiline', function
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toBe('--build-arg NO_FLAG_VAR');
expect($arg)->toBe("--build-arg 'NO_FLAG_VAR'");
});
test('generateDockerEnvFlags produces correct format', function () {
@@ -81,3 +81,17 @@ test('generateDockerEnvFlags works with collection input', function () {
expect($envFlags)->toContain('-e VAR1=');
expect($envFlags)->toContain('-e VAR2="');
});
test('generateDockerBuildArgs escapes legacy keys', function () {
$variables = [
['key' => 'BAD$(id)', 'value' => '1'],
['key' => "BAD'KEY", 'value' => '1'],
];
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs->values()->toArray())->toBe([
"--build-arg 'BAD$(id)'",
"--build-arg 'BAD'\''KEY'",
]);
});
+33
View File
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
@@ -100,6 +101,38 @@ describe('GitHub Manual Webhook HMAC', function () {
});
});
describe('GitHub App Webhook HMAC', function () {
test('rejects push when app webhook secret is empty', function () {
$team = Team::factory()->create();
GithubApp::create([
'uuid' => (string) str()->uuid(),
'name' => 'github-app-webhook-test',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 1234567890,
'webhook_secret' => null,
'team_id' => $team->id,
]);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['id' => 987654321],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
});
describe('GitLab Manual Webhook HMAC', function () {
test('rejects push when secret is empty', function () {
$app = createApplicationWithWebhook();
+87 -31
View File
@@ -4,11 +4,50 @@ use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Illuminate\Support\Collection;
afterEach(function () {
Mockery::close();
});
function commandStrings(array|Collection|string $commands): Collection
{
if (is_string($commands)) {
return collect([$commands]);
}
return collect($commands)->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command);
}
function privateKeyMaterializationCommands(array|Collection|string $commands): Collection
{
if (is_string($commands)) {
$commands = [$commands];
}
return collect($commands)->filter(fn ($command) => str(commandStrings([$command])->first())->contains('base64 -d | tee /root/.ssh/id_rsa_coolify_'));
}
function expectCommandListToContain(array|Collection|string $commands, string $expected): void
{
expect(commandStrings($commands)->implode(' && '))->toContain($expected);
}
function expectCommandListNotToContain(array|Collection|string $commands, string $expected): void
{
expect(commandStrings($commands)->implode(' && '))->not->toContain($expected);
}
function expectPrivateKeyMaterializationCommandsSkipLogging(array|Collection|string $commands): void
{
$keyCommands = privateKeyMaterializationCommands($commands);
expect($keyCommands)->not->toBeEmpty();
$keyCommands->each(function ($command): void {
expect(data_get($command, 'skip_command_log'))->toBeTrue();
});
}
/**
* Git operations authenticate with the SSH key assigned in the UI. Coolify writes that key to a
* per-deployment path (/root/.ssh/id_rsa_coolify_<deployment_uuid>) instead of the shared
@@ -19,6 +58,24 @@ afterEach(function () {
*/
$keyPath = '/root/.ssh/id_rsa_coolify_test-deployment-uuid';
it('skips logging the docker deploy key materialization command before ls-remote', function () {
$source = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$commandPosition = strpos($source, 'base64 -d | tee {$customSshKeyLocation}');
expect($commandPosition)->not->toBeFalse()
->and(substr($source, $commandPosition, 200))->toContain("'skip_command_log' => true");
});
it('supports skipping command log entries without adding a hidden command entry', function () {
$source = file_get_contents(__DIR__.'/../../app/Traits/ExecuteRemoteCommand.php');
expect($source)
->toContain('$skip_command_log = data_get($single_command, \'skip_command_log\', false);')
->toContain('if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue))')
->toContain('use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log)')
->toContain('\'command\' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command),');
});
it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
@@ -31,11 +88,11 @@ it('writes a deploy key to a per-deployment path and cleans it up for ls-remote
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT") // removed when the shell exits
->not->toContain('tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT"); // removed when the shell exits
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) {
@@ -50,11 +107,11 @@ it('writes a deploy key to a per-deployment path for ls-remote inside docker wit
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', true);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->not->toContain('trap ') // ephemeral container, no cleanup needed
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListNotToContain($result['commands'], 'trap '); // ephemeral container, no cleanup needed
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) {
@@ -77,11 +134,11 @@ it('writes a GitLab source private key to a per-deployment path with cleanup on
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT");
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
@@ -104,11 +161,11 @@ it('writes a deploy key to a per-deployment path and cleans it up when cloning o
// exec_in_docker = false → the loadComposeFile / host clone path
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT");
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
@@ -137,11 +194,11 @@ it('writes a GitLab source private key to a per-deployment path and cleans it up
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT");
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) {
@@ -163,9 +220,9 @@ it('uses the per-deployment deploy key for pull request fetches', function () us
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
expect($result['commands'])
->toContain("GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify")
->not->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
expectCommandListToContain($result['commands'], "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify");
expectCommandListNotToContain($result['commands'], 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) {
@@ -183,7 +240,6 @@ it('does not force a missing per-deployment key for other repository pull reques
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
expect($result['commands'])
->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify')
->not->toContain($keyPath);
expectCommandListToContain($result['commands'], 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
expectCommandListNotToContain($result['commands'], $keyPath);
});
+47 -9
View File
@@ -3,11 +3,45 @@
use App\Models\Application;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Illuminate\Support\Collection;
afterEach(function () {
Mockery::close();
});
function gitlabCommandStrings(array|Collection|string $commands): Collection
{
if (is_string($commands)) {
return collect([$commands]);
}
return collect($commands)->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command);
}
function expectGitlabCommandListToContain(array|Collection|string $commands, string $expected): void
{
expect(gitlabCommandStrings($commands)->implode(' && '))->toContain($expected);
}
function expectGitlabCommandListNotToContain(array|Collection|string $commands, string $expected): void
{
expect(gitlabCommandStrings($commands)->implode(' && '))->not->toContain($expected);
}
function expectGitlabPrivateKeyMaterializationCommandsSkipLogging(array|Collection|string $commands): void
{
if (is_string($commands)) {
$commands = [$commands];
}
$keyCommands = collect($commands)->filter(fn ($command) => str(data_get($command, 'command') ?? $command[0] ?? $command)->contains('base64 -d | tee /root/.ssh/id_rsa_coolify_'));
expect($keyCommands)->not->toBeEmpty();
$keyCommands->each(function ($command): void {
expect(data_get($command, 'skip_command_log'))->toBeTrue();
});
}
it('generates ls-remote commands for GitLab source with private key', function () {
$deploymentUuid = 'test-deployment-uuid';
@@ -15,7 +49,8 @@ it('generates ls-remote commands for GitLab source with private key', function (
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
@@ -34,16 +69,18 @@ it('generates ls-remote commands for GitLab source with private key', function (
expect($result)->toBeArray();
expect($result)->toHaveKey('commands');
expect($result['commands'])->toContain('git ls-remote');
expect($result['commands'])->toContain('id_rsa');
expect($result['commands'])->toContain('mkdir -p /root/.ssh');
expectGitlabCommandListToContain($result['commands'], 'git ls-remote');
expectGitlabCommandListToContain($result['commands'], 'id_rsa');
expectGitlabCommandListToContain($result['commands'], 'mkdir -p /root/.ssh');
expectGitlabPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('generates ls-remote commands for GitLab source without private key', function () {
$deploymentUuid = 'test-deployment-uuid';
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
@@ -61,17 +98,18 @@ it('generates ls-remote commands for GitLab source without private key', functio
expect($result)->toBeArray();
expect($result)->toHaveKey('commands');
expect($result['commands'])->toContain('git ls-remote');
expect($result['commands'])->toContain('https://gitlab.com/user/repo.git');
expectGitlabCommandListToContain($result['commands'], 'git ls-remote');
expectGitlabCommandListToContain($result['commands'], 'https://gitlab.com/user/repo.git');
// Should NOT contain SSH key setup
expect($result['commands'])->not->toContain('id_rsa');
expectGitlabCommandListNotToContain($result['commands'], 'id_rsa');
});
it('does not return null for GitLab source type', function () {
$deploymentUuid = 'test-deployment-uuid';
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
+15 -8
View File
@@ -132,24 +132,31 @@ it('generates nullable dockerNetworkRules when not required', function () {
->not->toContain('required');
});
it('accepts Docker-compatible environment variable keys', function (string $key) {
it('accepts shell-safe 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',
'lowercase' => 'node_version',
'dot notation' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
]);
it('rejects environment variable keys Docker cannot represent', function (string $key) {
it('rejects invalid environment variable keys', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse();
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
'command substitution' => 'BAD$(id)',
'backticks' => 'BAD`id`',
'pipe' => 'BAD|id',
'ampersand' => 'BAD&id',
'newline' => 'BAD
KEY',
'equals' => 'BAD=KEY',
'empty' => '',
]);
@@ -164,7 +171,7 @@ it('generates environment variable key rules with correct defaults', function ()
});
it('normalizes environment variable keys by trimming surrounding whitespace', function () {
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name');
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' APP_ENV '))->toBe('APP_ENV');
});
it('normalizes environment variable keys before model validation', function () {