Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai committed 2026-06-30 21:31:25 +02:00
commit 559cdbff25
63 files changed
+2286 -1191

No files matched your search

@@ -3,6 +3,8 @@
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
use Tests\TestCase;
@@ -236,10 +238,15 @@ it('builds railpack docker command with matching env and secret flags for all ra
],
);
// Build-time variables are interpolated by sourcing the build-time .env file before
// the build, so user/Coolify variables must NOT be forwarded inline as literals.
expect($command)->toContain('set -a && source /artifacts/build-time.env && set +a');
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\"}'");
// SECRET_JSON is not a buildpack control variable, so it is provided via the sourced
// build-time .env file (which supports $VAR interpolation) rather than inline `env`.
expect($command)->not->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'");
@@ -247,3 +254,69 @@ it('builds railpack docker command with matching env and secret flags for all ra
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').'"');
});
it('interpolates build-time variable references for railpack by sourcing the build-time env file', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
// Mirrors the issue: BETTER_AUTH_URL=$COOLIFY_URL must be interpolated at build time.
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([
'BETTER_AUTH_URL' => '$COOLIFY_URL',
'COOLIFY_URL' => 'https://sapere-10.bobman.dev',
]),
],
);
// The literal `$COOLIFY_URL` must NOT be forwarded inline; it is resolved by the shell
// after sourcing the build-time .env file, then read through the build secret.
expect($command)->toContain('set -a && source /artifacts/build-time.env && set +a');
expect($command)->not->toContain("'BETTER_AUTH_URL=\$COOLIFY_URL'");
expect($command)->not->toContain("env 'BETTER_AUTH_URL");
expect($command)->toContain("--secret 'id=BETTER_AUTH_URL,env=BETTER_AUTH_URL'");
expect($command)->toContain("--secret 'id=COOLIFY_URL,env=COOLIFY_URL'");
});
it('creates an empty build-time env file for railpack when there are no generated build-time variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'build_pack' => 'railpack',
'compose_parsing_version' => '3',
]);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$application = $applicationProperty->getValue($job);
$application->setRelation('settings', new ApplicationSetting([
'include_source_commit_in_build' => false,
'is_env_sorting_enabled' => false,
]));
$application->setRelation('environment_variables', collect([
new EnvironmentVariable(['key' => 'COOLIFY_FQDN']),
new EnvironmentVariable(['key' => 'COOLIFY_URL']),
new EnvironmentVariable(['key' => 'COOLIFY_BRANCH']),
new EnvironmentVariable(['key' => 'COOLIFY_RESOURCE_UUID']),
]));
foreach ([
'application_deployment_queue' => new class
{
public function addLogEntry(string $message, string $type = 'info', bool $hidden = false): void {}
},
'build_pack' => 'railpack',
'pull_request_id' => 0,
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
invokeRailpackMethod($job, $reflection, 'save_buildtime_environment_variables');
expect(collect($job->recordedCommands)->flatten()->implode(' '))->toContain('touch /artifacts/build-time.env');
});
+1 -1
View File
@@ -182,7 +182,7 @@ test('escapeshellarg neutralizes command injection in mariadb password', functio
$escaped = escapeshellarg($maliciousPassword);
// Single quotes in the value get escaped as '\''
expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''");
expect($escaped)->toBe("'pass'\\''; whoami; echo '\\'''");
$command = "docker exec container mariadb-dump -u root -p$escaped db";
// Verify the command doesn't contain an unescaped semicolon outside quotes
expect($command)->toContain("-p'pass'");
+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);
@@ -34,7 +34,7 @@ test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain("gunzip -cf '/tmp/test.dump'");
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
@@ -46,7 +46,7 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
expect($result)->toContain('< /tmp/test.dump');
expect($result)->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
@@ -57,7 +57,7 @@ test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
expect($result)->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
@@ -68,5 +68,5 @@ test('buildRestoreCommand always appends the MongoDB archive path', function (bo
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('--archive=/tmp/test.dump');
expect($result)->toContain("--archive='/tmp/test.dump'");
})->with([false, true]);
+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 () {