Ctrl K
ComunidadGuías…

tests/Feature/ApplicationDeploymentControlVarFilteringTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

use App\Exceptions\DeploymentException;
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 App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;

uses(RefreshDatabase::class);

it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'APP_SECRET',
        'value' => 'sensitive-value',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'configuration_dir' => '/data/coolify/applications/test-app',
        'remote_secrets_cache' => [],
        'saved_outputs' => [
            'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
        ],
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'save_runtime_environment_variables');
    invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables');
    invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');

    $writeCommands = collect($job->recordedCommands)
        ->flatMap(fn (array $commands): array => $commands)
        ->filter(function (mixed $command): bool {
            if (! is_array($command)) {
                return false;
            }

            $commandString = $command['command'] ?? $command[0] ?? null;

            return is_string($commandString) && str_contains($commandString, 'base64 -d | tee');
        })
        ->values();

    expect($writeCommands)->toHaveCount(4)
        ->each->toHaveKey('skip_command_log', true);
});

it('redacts resolved remote secrets from command output', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'remote_secrets_cache' => ['API_TOKEN' => 'remote-secret-value'],
    ]);

    expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'token=remote-secret-value'))
        ->toBe('token='.REDACTED);
});

it('does not retain locked values from generated build-time debug logs', function () {
    config()->set('app.env', 'local');
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'SINGLE_MARKER',
        'value' => 'harmless-single-marker',
        'is_shown_once' => true,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'MULTILINE_MARKER',
        'value' => "harmless-first-marker\nharmless-second-marker",
        'is_multiline' => true,
        'is_shown_once' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);
    invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');

    $deployment = ApplicationDeploymentQueue::create([
        'deployment_uuid' => 'harmless-debug-log-deployment',
        'application_id' => $application->id,
        'server_id' => $server->id,
    ]);
    foreach ($job->recordedLogEntries as $entry) {
        $deployment->addLogEntry($entry);
    }

    $retainedLogs = $deployment->fresh()->logs;
    expect($retainedLogs)
        ->not->toContain('harmless-single-marker')
        ->not->toContain('harmless-first-marker')
        ->not->toContain('harmless-second-marker');

    $member = User::factory()->create();
    $application->team()->members()->attach($member->id, ['role' => 'member']);
    $application->settings->update(['is_debug_enabled' => true]);
    $this->actingAs($member);

    $visibleLines = decode_remote_command_output($deployment->fresh())->pluck('line')->implode("\n");
    expect($visibleLines)
        ->toContain('[DEBUG]')
        ->not->toContain('harmless-first-marker')
        ->not->toContain('harmless-second-marker');
});

it('does not print build-time values in the development debug lines', function () {
    config()->set('app.env', 'local');
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'normal-secret-marker']);
    createApplicationEnvironmentVariable($application, ['key' => 'LITERAL_TOKEN', 'value' => 'literal-secret-marker', 'is_literal' => true]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);
    invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');

    $debugLines = collect($job->recordedLogEntries)->filter(fn (string $line) => str_contains($line, '[DEBUG]'))->implode("\n");
    expect($debugLines)
        ->toContain('[DEBUG] Build-time env: DB_PASSWORD')
        ->toContain('[DEBUG] Build-time env: LITERAL_TOKEN')
        ->not->toContain('normal-secret-marker')
        ->not->toContain('literal-secret-marker');
});

it('redacts generated multiline forms in remote command and output logging', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    createApplicationEnvironmentVariable($application, [
        'key' => 'MULTILINE_MARKER',
        'value' => "harmless-first-marker\nharmless-second-marker",
        'is_multiline' => true,
        'is_shown_once' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);
    $generated = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')
        ->first(fn (string $line): bool => str_starts_with($line, 'MULTILINE_MARKER='));

    foreach ([$generated, str_replace("\n", '\\n', $generated), 'harmless-second-marker'] as $loggedForm) {
        $redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'output: '.$loggedForm);
        expect($redacted)
            ->not->toContain('harmless-first-marker')
            ->not->toContain('harmless-second-marker')
            ->toContain('output: ');
    }
});

it('keeps deployment logging available if value formatting fails', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    $variable = new class extends EnvironmentVariable
    {
        public function logRedactionValues(?Collection $sharedVariables = null): array
        {
            throw new RuntimeException('Harmless formatting failure');
        }
    };
    $variable->is_shown_once = true;

    $application->setRelation('environment_variables', collect([$variable]));
    $deployment = ApplicationDeploymentQueue::create([
        'deployment_uuid' => 'harmless-formatting-failure',
        'application_id' => $application->id,
        'server_id' => $server->id,
    ]);
    $deployment->setRelation('application', $application);
    $deployment->addLogEntry('Harmless log text');

    expect(json_decode($deployment->fresh()->logs, true)[0]['output'])->toBe(REDACTED);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);
    readDeploymentJobProperty($job, $reflection, 'application')
        ->setRelation('environment_variables', collect([$variable]));

    expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'Harmless command text'))
        ->toBe(REDACTED);
});

it('redacts shell-escaped remote secrets from command output', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'remote_secrets_cache' => ['DB_PASS' => "s3cr'et-value"],
    ]);

    $redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', "docker build --build-arg DB_PASS='s3cr'\\''et-value' --env 'DB_PASS=s3cr'\\''et-value' .");

    expect($redacted)->not->toContain('s3cr')
        ->toContain('--build-arg DB_PASS='.REDACTED);
});

it('ignores empty and non-string remote secrets when redacting command output', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'remote_secrets_cache' => [
            'EMPTY_SECRET' => '',
            'NULL_SECRET' => null,
            'NUMERIC_SECRET' => 123,
            'API_TOKEN' => 'remote-secret-value',
        ],
    ]);

    expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'id=123 token=remote-secret-value'))
        ->toBe('id=123 token='.REDACTED);
});

class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
{
    public array $recordedCommands = [];

    public array $recordedLogEntries = [];

    public array $writtenArtifacts = [];

    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;
            }

            if (preg_match('~echo .*?([A-Za-z0-9+/=]{8,}).*?\\| base64 -d \\| tee (/artifacts/[^ ]+) > /dev/null~', $commandString, $matches) === 1) {
                $this->writtenArtifacts[$matches[2]] = base64_decode($matches[1]);
            }
        }
    }
}

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
{
    $attributes = [
        'resourceable_type' => Application::class,
        'resourceable_id' => $application->id,
        'is_preview' => false,
        'is_runtime' => true,
        'is_buildtime' => true,
        'is_multiline' => false,
        'is_literal' => false,
        ...$attributes,
    ];

    // The created hooks of the application already add defaults such as NIXPACKS_NODE_VERSION.
    return EnvironmentVariable::updateOrCreate(
        Arr::only($attributes, ['resourceable_type', 'resourceable_id', 'is_preview', 'key']),
        $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')->andReturnUsing(function (string $message, string $type = 'stdout', bool $hidden = false) use ($job) {
        $job->recordedLogEntries[] = $message;

        return null;
    });

    $properties = [
        'application' => $application->fresh(),
        'application_deployment_queue' => $queue,
        'build_pack' => $application->build_pack,
        'mainServer' => $server,
        'pull_request_id' => 0,
        'commit' => 'HEAD',
        'basedir' => '/artifacts/test-app',
        '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 ...$arguments): mixed
{
    $reflectionMethod = $reflection->getMethod($method);
    $reflectionMethod->setAccessible(true);

    return $reflectionMethod->invoke($job, ...$arguments);
}

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('rejects unsafe Nixpacks plan variable keys before writing the build-time env file', function (string $key) {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                $key => 'value',
            ],
        ]),
    ]);

    expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'))
        ->toThrow(DeploymentException::class);
})->with([
    'command substitution' => 'X$(id)',
    'backticks' => 'X`id`',
    'newline' => "X\nid",
    'shell assignment' => 'X=value',
    'semicolon' => 'X;id',
    'pipe' => 'X|id',
    'ampersand' => 'X&id',
    'leading dollar' => '$(id)',
    'command substitution with arguments' => 'X$(docker run --rm -v /:/mnt alpine true)',
]);

it('keeps persisted dotted user build-time variable keys', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'X.VALUE',
        'value' => 'unsafe',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    /** @var Collection $buildtimeEnvs */
    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');

    expect($buildtimeEnvs)->toContain('X.VALUE="unsafe"');
});

it('loads shell variables and passes dotted variables through the build-time environment launcher', function () {
    $temporaryDirectory = sys_get_temp_dir().'/coolify-build-env-'.str()->random(8);
    expect(mkdir($temporaryDirectory))->toBeTrue();
    $shellEnvironmentPath = $temporaryDirectory.'/build-time-shell.env';
    $launcherPath = $temporaryDirectory.'/run-with-build-time-env';
    $injectionMarkerPath = $temporaryDirectory.'/injection-marker';

    try {
        [$application, $server] = makeDeploymentControlVarFixture();

        createApplicationEnvironmentVariable($application, [
            'key' => 'BASE_VALUE',
            'value' => 'expanded',
        ]);
        createApplicationEnvironmentVariable($application, [
            'key' => 'X.VALUE',
            'value' => '$BASE_VALUE',
        ]);
        createApplicationEnvironmentVariable($application, [
            'key' => 'DOTTED.VALUE',
            'value' => "$(touch {$injectionMarkerPath})",
        ]);

        [$job, $reflection] = makeControlVarFilteringJob($application, $server);

        invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables');

        expect($job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_PATH])
            ->toContain('BASE_VALUE="expanded"')
            ->toContain('X.VALUE="$BASE_VALUE"');
        expect($job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH])
            ->toContain('BASE_VALUE="expanded"')
            ->not->toContain('X.VALUE');
        expect($job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH])
            ->toContain('source '.ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH)
            ->toContain('X.VALUE="$BASE_VALUE"')
            ->toContain('exec env');

        $wrappedCommand = invokeDeploymentJobMethod($job, $reflection, 'wrap_build_command_with_env_export', 'printenv X.VALUE');

        expect($wrappedCommand)
            ->toContain(ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH)
            ->toContain("/bin/bash -c 'export DOCKER_CONFIG=/root/.docker && printenv X.VALUE'")
            ->not->toContain('source '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH);

        file_put_contents($shellEnvironmentPath, $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH]);
        file_put_contents(
            $launcherPath,
            str_replace(
                'source '.ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH,
                'source '.$shellEnvironmentPath,
                $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH],
            ),
        );
        chmod($launcherPath, 0700);

        $process = new Process(['/bin/bash', $launcherPath, '/bin/bash', '-c', 'printenv X.VALUE; printenv DOTTED.VALUE']);
        $process->mustRun();

        expect($process->getOutput())
            ->toContain("expanded\n")
            ->toContain("$(touch {$injectionMarkerPath})");
        expect(file_exists($injectionMarkerPath))->toBeFalse();
    } finally {
        @unlink($launcherPath);
        @unlink($shellEnvironmentPath);
        @unlink($injectionMarkerPath);
        @rmdir($temporaryDirectory);
    }
});

it('keeps the original sourced environment path when build-time keys are shell safe', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_VALUE',
        'value' => 'safe',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables');

    expect($job->writtenArtifacts)
        ->toHaveKey(ApplicationDeploymentJob::BUILD_TIME_ENV_PATH)
        ->not->toHaveKey(ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH)
        ->not->toHaveKey(ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH);

    $wrappedCommand = invokeDeploymentJobMethod($job, $reflection, 'wrap_build_command_with_env_export', 'docker build .');

    expect($wrappedCommand)
        ->toContain('set -a && source '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH.' && set +a && export DOCKER_CONFIG=/root/.docker && docker build .')
        ->not->toContain(ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH);
});

it('keeps the Docker CLI on the helper plugins when build-time variables set HOME or DOCKER_CONFIG', function (bool $withDottedVariable) {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, ['key' => 'HOME', 'value' => '/app']);
    createApplicationEnvironmentVariable($application, ['key' => 'DOCKER_CONFIG', 'value' => '/app/.docker']);
    if ($withDottedVariable) {
        createApplicationEnvironmentVariable($application, ['key' => 'X.VALUE', 'value' => 'dotted']);
    }

    $temporaryDirectory = sys_get_temp_dir().'/coolify-docker-config-'.str()->random(8);
    expect(mkdir($temporaryDirectory))->toBeTrue();

    try {
        [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['workdir' => $temporaryDirectory]);
        invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables');

        $localPaths = [];
        foreach ($job->writtenArtifacts as $artifactPath => $contents) {
            $localPath = $temporaryDirectory.'/'.basename($artifactPath);
            $localPaths[$artifactPath] = $localPath;
            file_put_contents($localPath, $contents);
        }
        foreach ($localPaths as $artifactPath => $localPath) {
            file_put_contents($localPath, strtr(file_get_contents($localPath), $localPaths));
        }

        $wrappedCommand = invokeDeploymentJobMethod($job, $reflection, 'wrap_build_command_with_env_export', 'echo "$HOME|$DOCKER_CONFIG"');

        $process = new Process(['/bin/bash', '-c', strtr($wrappedCommand, $localPaths)], env: ['HOME' => '/root']);
        $process->mustRun();

        // HOME keeps the user value for --build-arg HOME, but the Docker CLI finds buildx in /root/.docker/cli-plugins.
        expect(trim($process->getOutput()))->toBe('/app|/root/.docker');
    } finally {
        foreach (glob($temporaryDirectory.'/*') as $file) {
            @unlink($file);
        }
        @rmdir($temporaryDirectory);
    }
})->with([
    'sourced build-time env file' => [false],
    'build-time env launcher' => [true],
]);

it('skips dotted keys from the Nixpacks plan without failing the deployment', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'spring.profiles.active',
        'value' => 'prod',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
        'saved_outputs' => collect([
            'nixpacks_plan' => json_encode([
                'variables' => [
                    'X.VALUE' => 'dotted-from-toml',
                    'SAFE_FROM_TOML' => 'ok-from-toml',
                    'NIXPACKS_NODE_VERSION' => '22',
                ],
            ]),
            'nixpacks_type' => 'node',
        ]),
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'generate_nixpacks_confs');

    $variables = collect(readDeploymentJobProperty($job, $reflection, 'nixpacks_plan_json')->get('variables'));

    expect($variables->has('SAFE_FROM_TOML'))->toBeTrue();
    expect($variables->has('X.VALUE'))->toBeFalse();
    expect($variables->has('spring.profiles.active'))->toBeFalse();

    $logs = implode("\n", $job->recordedLogEntries);

    expect($logs)
        ->toContain('Build-time variable X.VALUE has a dot in its name, which Nixpacks cannot pass to the build')
        ->toContain('Build-time variable spring.profiles.active has a dot in its name')
        ->toContain('Suggested name: X_VALUE')
        ->toContain('Available at Buildtime')
        ->not->toContain('dotted-from-toml')
        ->not->toContain('prod');
});

it('still allows underscore Nixpacks plan keys through generate_build_env_variables', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                'SAFE_FROM_TOML' => 'ok-from-toml',
                'NIXPACKS_NODE_VERSION' => '22',
            ],
        ]),
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables');

    expect(readDeploymentJobProperty($job, $reflection, 'dockerSecretsSupported'))->toBeFalse();
});

it('mounts underscore nixpacks secrets on RUN lines when build secrets are enabled', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'env_args' => collect(['SAFE_FROM_TOML' => 'ok-from-toml']),
        'nixpacks_plan_json' => collect([
            'variables' => ['SAFE_FROM_TOML' => 'ok-from-toml'],
        ]),
        'build_secrets' => '--secret id=SAFE_FROM_TOML,env=SAFE_FROM_TOML',
        'saved_outputs' => collect([
            'dockerfile_content' => "FROM alpine\nARG SAFE_FROM_TOML\nRUN printenv SAFE_FROM_TOML",
        ]),
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/.nixpacks/Dockerfile');

    expect($job->writtenArtifacts['/artifacts/test-app/.nixpacks/Dockerfile'])
        ->toContain('RUN --mount=type=secret,id=SAFE_FROM_TOML,env=SAFE_FROM_TOML')
        ->toContain('printenv SAFE_FROM_TOML');
});

it('skips unsafe reserved Nixpacks plan variable keys before validation', function (string $key) {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                $key => 'value',
            ],
        ]),
    ]);

    /** @var Collection $buildtimeEnvs */
    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');

    expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith($key.'=')))->toBeFalse();
})->with([
    'Coolify key' => 'COOLIFY_$(id)',
    'service key' => 'SERVICE_$(id)',
]);

it('explains invalid Nixpacks plan variable keys in deployment logs', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                'XPACK;SECURITY;ENABLED' => 'true',
            ],
        ]),
    ]);

    expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'))
        ->toThrow(DeploymentException::class, 'Invalid environment variable name from the Nixpacks plan: XPACK;SECURITY;ENABLED');

    $logs = implode("\n", $job->recordedLogEntries);

    expect($logs)
        ->toContain('Invalid environment variable name from the Nixpacks plan: XPACK;SECURITY;ENABLED')
        ->toContain('must start with a letter or underscore')
        ->toContain('How to fix')
        ->toContain('nixpacks.toml')
        ->toContain('https://nixpacks.com/docs/configuration/file');
});

it('marks an invalid variable name failure as already explained in the deployment log', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                'XPACK;SECURITY;ENABLED' => 'true',
            ],
        ]),
    ]);

    try {
        invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
        $this->fail('The deployment did not stop.');
    } catch (DeploymentException $exception) {
        expect($exception->isMessageAlreadyLogged())->toBeTrue();
    }
});

it('truncates long Nixpacks plan variable keys in deployment logs', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    $key = 'X$(docker run --rm alpine sh -c "'.str_repeat('a', 200).'TAIL_SHOULD_BE_TRUNCATED")';

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                $key => 'x',
            ],
        ]),
    ]);

    expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'))
        ->toThrow(DeploymentException::class);

    $logs = implode("\n", $job->recordedLogEntries);

    expect($logs)
        ->toContain('Invalid environment variable name from the Nixpacks plan: X$(docker run --rm')
        ->toContain('...')
        ->not->toContain('TAIL_SHOULD_BE_TRUNCATED')
        ->toContain('nixpacks.toml');
});

it('bounds every deployment log entry for long invalid Nixpacks variable keys', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    $key = 'X'.str_repeat('$', 10_000);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                $key => 'x',
            ],
        ]),
    ]);

    expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'))
        ->toThrow(DeploymentException::class);

    $longestLogEntryLength = max(array_map(strlen(...), $job->recordedLogEntries));

    expect($longestLogEntryLength)->toBeLessThanOrEqual(200);
});

it('keeps shell-safe Nixpacks plan variables in the build-time env file', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'nixpacks',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'nixpacks_plan_json' => collect([
            'variables' => [
                'APP_NAME' => 'coolify',
                '_PRIVATE_VALUE' => 'secret',
                'X.VALUE' => 'dotted',
            ],
        ]),
    ]);

    /** @var Collection $buildtimeEnvs */
    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');

    expect($buildtimeEnvs)->toContain("APP_NAME='coolify'")
        ->toContain("_PRIVATE_VALUE='secret'")
        ->toContain("X.VALUE='dotted'");
});

it('does not let preview docker compose service names override generated build-time service names', function () {
    $compose = <<<'YAML'
services:
  app:
    image: nginx
  postgresapp:
    image: postgres:16-alpine
YAML;

    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => $compose,
        'docker_compose' => $compose,
        'docker_compose_domains' => '[]',
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'SERVICE_NAME_POSTGRESAPP',
        'value' => '',
        'is_preview' => true,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'SERVICE_URL_APP',
        'value' => '',
        'is_preview' => true,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'pull_request_id' => 241,
    ]);

    /** @var Collection $buildtimeEnvs */
    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
    $envString = $buildtimeEnvs->implode("\n");

    expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'");
    expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""');
    expect($envString)->not->toContain('SERVICE_URL_APP=');
});

it('does not let production docker compose service names override generated build-time service names', function () {
    $compose = <<<'YAML'
services:
  app:
    image: nginx
  postgresapp:
    image: postgres:16-alpine
YAML;

    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => $compose,
        'docker_compose' => $compose,
        'docker_compose_domains' => '[]',
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'SERVICE_NAME_POSTGRESAPP',
        'value' => 'stale-postgresapp',
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    /** @var Collection $buildtimeEnvs */
    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
    $envString = $buildtimeEnvs->implode("\n");

    expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'");
    expect($envString)->not->toContain('stale-postgresapp');
});

it('filters docker compose generated service variables from build args', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'APP_ENV',
        'value' => 'production',
        'is_preview' => true,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'SERVICE_NAME_POSTGRESAPP',
        'value' => '',
        'is_preview' => true,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'SERVICE_URL_APP',
        'value' => 'https://preview.example.com',
        'is_preview' => true,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'pull_request_id' => 241,
    ]);

    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('SERVICE_NAME_POSTGRESAPP'))->toBeFalse();
    expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse();
});

it('falls back to production runtime vars without a preview copy only when public PR deployments are off', function (bool $isPublic) {
    [$application, $server] = makeDeploymentControlVarFixture();
    $application->settings()->update(['is_pr_deployments_public_enabled' => $isPublic]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'APP_NAME',
        'value' => 'coolify',
        '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()->where('key', 'APP_NAME')->delete();

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $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=')))->toBe(! $isPublic);
    expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue();
})->with([
    'public PR deployments off' => [false],
    'public PR deployments on' => [true],
]);

it('does not give production runtime values to a preview copy that is not a runtime variable', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => "services:\n  app:\n    image: nginx\n",
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'DB_PASSWORD',
        'value' => 'production-secret',
        '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()->where('key', 'DB_PASSWORD')->update(['is_runtime' => false]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'pull_request_id' => 99,
    ]);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs->filter(fn (string $env) => str($env)->contains('production-secret')))->toBeEmpty();
});

it('keeps copied production values out of previews when public PR deployments are on', function (string $buildPack) {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => $buildPack,
        'docker_compose_raw' => "services:\n  app:\n    image: nginx\n",
    ]);
    $application->settings()->update(['is_pr_deployments_public_enabled' => true]);

    createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'production-secret']);
    createApplicationEnvironmentVariable($application, ['key' => 'API_URL', 'value' => 'https://api.example.com']);
    createApplicationEnvironmentVariable($application, ['key' => 'PORT', 'value' => '3000']);
    $application->environment_variables_preview()->where('key', 'API_URL')->first()
        ->update(['value' => 'https://preview-api.example.com']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
        'pull_request_id' => 99,
        'remote_secrets_cache' => [],
    ]);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
    /** @var Collection $buildtimeEnvs */
    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
    invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
    /** @var Collection $envArgs */
    $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
    /** @var Collection $railpackVariables */
    $railpackVariables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');

    foreach ([$runtimeEnvs, $buildtimeEnvs] as $envLines) {
        expect($envLines->filter(fn (string $env) => str($env)->contains('production-secret')))->toBeEmpty()
            ->and($envLines->contains(fn (string $env) => str($env)->startsWith('API_URL=') && str($env)->contains('preview-api.example.com')))->toBeTrue();
    }
    expect($envArgs->has('DB_PASSWORD'))->toBeFalse()
        ->and($envArgs->get('API_URL'))->toBe('https://preview-api.example.com')
        ->and($railpackVariables->has('DB_PASSWORD'))->toBeFalse()
        ->and($railpackVariables->get('API_URL'))->toBe('https://preview-api.example.com');
    if ($buildPack !== 'dockercompose') {
        expect($runtimeEnvs)->toContain('PORT='.$application->main_port()[0]);
    }
})->with(['dockerfile', 'dockercompose']);

it('keeps copied production values for previews when public PR deployments are off', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'production-secret']);

    [$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('DB_PASSWORD=')))->toBeTrue();
});

it('keeps buildpack variables read at container start in the runtime env', function (int $pullRequestId, bool $isPreview) {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'nixpacks']);

    foreach ([
        'APP_NAME' => 'coolify',
        'NIXPACKS_PHP_ROOT_DIR' => '/app/public',
        'NIXPACKS_PHP_FALLBACK_PATH' => '/index.php',
        'NIXPACKS_SPA_OUTPUT_DIR' => 'dist',
        'RAILPACK_SKIP_MIGRATIONS' => 'true',
        'NIXPACKS_NODE_VERSION' => '22',
        'RAILPACK_NODE_VERSION' => '20',
    ] as $key => $value) {
        createApplicationEnvironmentVariable($application, [
            'key' => $key,
            'value' => $value,
            'is_preview' => $isPreview,
        ]);
    }

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'pull_request_id' => $pullRequestId,
    ]);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)->toContain(
        'APP_NAME='.escapeComposeEnvFileValue('coolify'),
        'NIXPACKS_PHP_ROOT_DIR='.escapeComposeEnvFileValue('/app/public'),
        'NIXPACKS_PHP_FALLBACK_PATH='.escapeComposeEnvFileValue('/index.php'),
        'NIXPACKS_SPA_OUTPUT_DIR='.escapeComposeEnvFileValue('dist'),
        'RAILPACK_SKIP_MIGRATIONS='.escapeComposeEnvFileValue('true'),
    );
    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();
})->with([
    'production' => [0, false],
    'preview' => [42, true],
]);

it('keeps buildpack variables read at container start out of generic build args', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'NIXPACKS_PHP_ROOT_DIR',
        'value' => '/app/public',
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'RAILPACK_SKIP_MIGRATIONS',
        'value' => 'true',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');

    /** @var Collection $envArgs */
    $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');

    expect($envArgs->has('NIXPACKS_PHP_ROOT_DIR'))->toBeFalse();
    expect($envArgs->has('RAILPACK_SKIP_MIGRATIONS'))->toBeFalse();
});

it('creates missing Laravel defaults as preview variables for preview deployments', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'nixpacks']);

    $keys = ['NIXPACKS_PHP_ROOT_DIR', 'NIXPACKS_PHP_FALLBACK_PATH'];
    foreach (array_combine($keys, ['/app/web', '/app.php']) as $key => $value) {
        createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value]);
    }
    $application->environment_variables_preview()->whereIn('key', $keys)->delete();

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => 42]);

    [$fallbackPath, $rootDir] = invokeDeploymentJobMethod($job, $reflection, 'laravel_finetunes');

    expect($fallbackPath->is_preview)->toBeTrue()
        ->and($fallbackPath->value)->toBe('/index.php')
        ->and($rootDir->is_preview)->toBeTrue()
        ->and($rootDir->value)->toBe('/app/public')
        ->and($application->environment_variables()->whereIn('key', $keys)->pluck('value', 'key')->all())->toEqual([
            'NIXPACKS_PHP_ROOT_DIR' => '/app/web',
            'NIXPACKS_PHP_FALLBACK_PATH' => '/app.php',
        ])
        ->and($application->environment_variables_preview()->whereIn('key', $keys)->count())->toBe(2);
});

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, [
        'remote_secrets_cache' => [],
        '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');
    expect($job->writtenDockerfile)->toContain('ARG COOLIFY_BUILD_SECRETS_HASH=');
    expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
    expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
});

it('passes dotted keys as Docker build secrets for Dockerfile builds', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockerfile',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'dockerSecretsSupported' => true,
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'generate_build_secrets', collect([
        'DOTTED.USER' => 'df-dotted',
        'SAFE' => 'ok',
    ]));

    expect(readDeploymentJobProperty($job, $reflection, 'build_secrets'))
        ->toContain('id=DOTTED.USER,env=DOTTED.USER')
        ->toContain('id=SAFE,env=SAFE');
});

it('keeps dotted dockerfile build args when secrets are not used', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'DOTTED.USER',
        'value' => 'df-dotted',
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');

    /** @var Collection $envArgs */
    $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');

    expect($envArgs->get('DOTTED.USER'))->toBe('df-dotted');
});

it('does not write environment values into generated Dockerfile ARG declarations', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => "value\nRUN touch /tmp/injected",
        'is_runtime' => false,
        'is_buildtime' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'remote_secrets_cache' => [],
        'saved_outputs' => ['dockerfile' => 'FROM alpine'],
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');

    expect($job->writtenDockerfile)
        ->toContain('ARG SAFE_KEY')
        ->not->toContain('RUN touch /tmp/injected')
        ->not->toContain('value');
});

it('rejects unsafe keys before generating Railpack secret flags', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    expect(fn () => invokeDeploymentJobMethod(
        $job,
        $reflection,
        'railpack_build_secret_flags',
        collect(['BAD$(id)' => 'x']),
    ))->toThrow(DeploymentException::class, 'Invalid environment variable name from the Railpack environment');

    expect($job->recordedCommands)->toBeEmpty();
});

it('rejects unsafe legacy keys before generating BuildKit secret flags', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    expect(fn () => invokeDeploymentJobMethod(
        $job,
        $reflection,
        'generate_build_secrets',
        collect(['BAD$(id)' => 'secret']),
    ))->toThrow(DeploymentException::class, 'Invalid environment variable name from the build secret environment');

    expect($job->recordedCommands)->toBeEmpty();
});

it('rejects an unsafe stored build-time key before running a build command', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'secret',
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'BAD$(id)']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys', true))
        ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment');

    expect($job->recordedCommands)->toBeEmpty();
});

it('keeps deploying existing runtime-only variables whose names new variables cannot use', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'secret',
        'is_buildtime' => false,
    ]);
    // Names like my-var were accepted before the current rules; the model no longer allows them.
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
    invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys');

    expect(collect($job->recordedLogEntries)->implode("\n"))
        ->toContain('my-var')
        ->toContain('Suggested name: my_var');
    expect($job->recordedCommands)->toBeEmpty();
});

it('rejects existing variable names that would break the .env file or build commands', function (string $key, bool $isBuildtime, bool $buildsImage) {
    [$application, $server] = makeDeploymentControlVarFixture();
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'secret',
        'is_buildtime' => $isBuildtime,
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => $key]);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys', $buildsImage))
        ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment');
})->with([
    'runtime-only name with =' => ['A=B', false, false],
    'runtime-only name with a newline' => ["A\nB", false, false],
    'build-time name with shell characters' => ['my;var', true, true],
]);

it('does not fail restarts or deployments that reuse an image on invalid build-time names', function (string $buildPack) {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => $buildPack]);
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'secret',
        'is_buildtime' => true,
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'MY-VAR']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => $buildPack]);
    // handle() runs this check before it is known whether the deployment builds an image.
    invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys');

    expect(collect($job->recordedLogEntries)->implode("\n"))
        ->toContain('MY-VAR')
        ->toContain('Suggested name: MY_VAR')
        ->not->toContain('Invalid environment variable name');
    expect($job->recordedCommands)->toBeEmpty();
})->with(['dockerfile', 'nixpacks', 'static', 'railpack', 'dockercompose']);

it('keeps invalid build-time names out of the helper container so restarts with build secrets work', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'nixpacks']);
    $application->settings()->update(['use_build_secrets' => true]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'safe-build-value',
        'is_buildtime' => true,
    ]);
    $invalidVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'OTHER_KEY',
        'value' => 'invalid-build-value',
        'is_buildtime' => true,
    ]);
    DB::table('environment_variables')->where('id', $invalidVariable->id)->update(['key' => 'MY-VAR']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'nixpacks']);

    $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets');

    expect($envFlags)
        ->toContain("-e 'SAFE_KEY=safe-build-value'")
        ->not->toContain('MY-VAR')
        ->not->toContain('invalid-build-value');
});

it('warns instead of failing for invalid build-time names when the deployment does not build an image', function (bool $isRuntime) {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']);
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'secret',
        'is_buildtime' => true,
        'is_runtime' => $isRuntime,
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
    invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys');

    expect(collect($job->recordedLogEntries)->implode("\n"))
        ->toContain('my-var')
        ->toContain('Suggested name: my_var')
        ->not->toContain('Invalid environment variable name');
    expect($job->recordedCommands)->toBeEmpty();
})->with([
    'build-time only' => [false],
    'build-time and runtime' => [true],
]);

it('warns instead of failing for invalid build-time preview names of Docker image deployments', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']);
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'secret',
        'is_buildtime' => true,
        'is_preview' => true,
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['pull_request_id' => 7]);
    invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys');

    expect(collect($job->recordedLogEntries)->implode("\n"))
        ->toContain('Suggested name: my_var')
        ->not->toContain('Invalid environment variable name');
});

it('still rejects Docker image variable names that would break the .env file', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']);
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'secret',
        'is_buildtime' => true,
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'A=B']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'))
        ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment');
});

it('skips existing build-time variables whose names new variables cannot use', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockerfile',
    ]);
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'legacy-value',
        'is_buildtime' => true,
        'is_runtime' => true,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'VALID_KEY',
        'value' => 'valid-value',
        'is_buildtime' => true,
        'is_runtime' => true,
    ]);
    // Names like my-var were accepted before the current rules; the model no longer allows them.
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
    invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys');

    expect(collect($job->recordedLogEntries)->implode("\n"))
        ->toContain('Build-time variable my-var uses a name that new variables cannot use. It is skipped during the build, but is still passed to the container.')
        ->toContain('Suggested name: my_var')
        ->not->toContain('legacy-value');

    /** @var Collection $buildtimeEnvs */
    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
    expect($buildtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'my-var=')))->toBeFalse();
    expect($buildtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'VALID_KEY=')))->toBeTrue();

    invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
    $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
    expect($envArgs->has('my-var'))->toBeFalse();
    expect($envArgs->get('VALID_KEY'))->not->toBeNull();

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
    expect($runtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'my-var=')))->toBeTrue();
});

it('skips existing build-time variables whose names new variables cannot use from Railpack builds', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'railpack',
    ]);
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'legacy-value',
        'is_buildtime' => true,
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);
    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
        'build_pack' => 'railpack',
        'branch' => 'main',
    ]);

    /** @var Collection $variables */
    $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');

    expect($variables->has('my-var'))->toBeFalse();
});

it('does not pass build-time variables to the helper container of Docker image deployments', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']);
    $application->settings()->update(['use_build_secrets' => true]);
    $environmentVariable = createApplicationEnvironmentVariable($application, [
        'key' => 'SAFE_KEY',
        'value' => 'harmless-build-value',
        'is_buildtime' => true,
    ]);
    DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    expect(invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'))->toBe('');
});

it('injects raw escaped remote secrets into Dockerfile args and hashes the same values', function (int $pullRequestId, bool $isPreview) {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'SECRET_TOKEN',
        'value' => '{{vault.API_TOKEN}}',
        'is_preview' => $isPreview,
        'is_runtime' => false,
        'is_buildtime' => true,
    ]);

    $secret = "secret\$value'quoted";
    $escapedSecret = escapeBashEnvValue($secret);
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'pull_request_id' => $pullRequestId,
        'remote_secrets_cache' => ['API_TOKEN' => $secret],
        'saved_outputs' => [
            'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
        ],
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');

    $expectedHash = invokeDeploymentJobMethod(
        $job,
        $reflection,
        'generate_secrets_hash',
        collect(['SECRET_TOKEN' => $escapedSecret]),
    );

    expect($job->writtenDockerfile)
        ->toContain('ARG SECRET_TOKEN')
        ->not->toContain($secret)
        ->toContain("ARG COOLIFY_BUILD_SECRETS_HASH={$expectedHash}")
        ->not->toContain('$$');
})->with([
    'production' => [0, false],
    'preview' => [99, true],
]);

it('injects Dockerfile args for plain build-time variables without a secret manager source', function (int $pullRequestId, bool $isPreview) {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'APP_ENV',
        'value' => 'production',
        'is_preview' => $isPreview,
        'is_runtime' => false,
        'is_buildtime' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'pull_request_id' => $pullRequestId,
        'saved_outputs' => [
            'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
        ],
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');

    $expectedHash = invokeDeploymentJobMethod(
        $job,
        $reflection,
        'generate_secrets_hash',
        collect(['APP_ENV' => escapeBashEnvValue('production')]),
    );

    expect($job->writtenDockerfile)
        ->toContain('ARG APP_ENV')
        ->toContain("ARG COOLIFY_BUILD_SECRETS_HASH={$expectedHash}");
    expect(readDeploymentJobProperty($job, $reflection, 'remote_secrets_cache'))->toBeNull();
})->with([
    'production' => [0, false],
    'preview' => [99, true],
]);

it('checks compose Dockerfiles with a portable command that skips missing files', function (bool $dockerfileExists) {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']);
    $workdir = sys_get_temp_dir().'/coolify-compose-dockerfile-'.str()->random(8);
    expect(mkdir($workdir))->toBeTrue();

    if ($dockerfileExists) {
        file_put_contents($workdir.'/Dockerfile', "FROM alpine\n");
    }

    try {
        [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
            'basedir' => $workdir,
            'workdir' => $workdir,
            'env_args' => collect(['APP_ENV' => 'production']),
        ]);

        invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfiles_for_compose', [
            'services' => ['api' => ['build' => ['context' => '.', 'dockerfile' => 'Dockerfile']]],
        ]);

        $checkCommand = collect($job->recordedCommands)->flatten(1)->firstWhere('save', 'dockerfile_check_api')[0];

        // The helper image ships BusyBox realpath, which accepts no options.
        expect($checkCommand)->not->toContain('realpath -');

        $process = Process::fromShellCommandline(str($checkCommand)->after('docker exec deployment-uuid ')->toString());
        $process->run();

        expect($process->getExitCode())->toBe(0);
        expect($process->getOutput())->toBe($dockerfileExists ? realpath($workdir.'/Dockerfile') : '');
    } finally {
        @unlink($workdir.'/Dockerfile');
        @rmdir($workdir);
    }
})->with([
    'existing Dockerfile' => [true],
    'missing Dockerfile' => [false],
]);

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('skips dotted keys from Railpack build variables without failing the deployment', function () {
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'railpack',
    ]);

    createApplicationEnvironmentVariable($application, [
        'key' => 'APP_ENV',
        'value' => 'production',
        'is_runtime' => false,
        'is_buildtime' => true,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'spring.profiles.active',
        'value' => 'prod',
        'is_runtime' => true,
        '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->has('spring.profiles.active'))->toBeFalse();
    expect(implode("\n", $job->recordedLogEntries))
        ->toContain('Build-time variable spring.profiles.active has a dot in its name, which Railpack cannot pass to the build')
        ->toContain('Suggested name: spring_profiles_active');
});

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();
});

it('writes compose-safe runtime env files for user values', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'QUOTED_VALUE',
        'value' => 'hello world; quotes" \'$`',
        'is_literal' => true,
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'EXPAND_ME',
        'value' => '$BOTH_PHASES',
        'is_literal' => false,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'BOTH_PHASES',
        'value' => 'both-phases-value',
        'is_literal' => true,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'INJECT_CMD',
        'value' => '$(touch /tmp/coolify-value-injection)',
        'is_literal' => true,
        'is_runtime' => true,
        'is_buildtime' => true,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'EDGE_QUOTES',
        'value' => "'keep these quotes'",
        'is_literal' => true,
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'MULTILINE_VALUE',
        'value' => "\$BOTH_PHASES\nsecond line",
        'is_literal' => false,
        'is_multiline' => true,
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)
        ->toContain('QUOTED_VALUE='.escapeComposeEnvFileValue('hello world; quotes" \'$`'))
        ->toContain('INJECT_CMD='.escapeComposeEnvFileValue('$(touch /tmp/coolify-value-injection)'))
        ->toContain('EXPAND_ME='.escapeComposeEnvFileValue('$BOTH_PHASES', allowInterpolation: true))
        ->toContain('EDGE_QUOTES='.escapeComposeEnvFileValue("'keep these quotes'"))
        ->toContain('MULTILINE_VALUE='.escapeComposeEnvFileValue("\$BOTH_PHASES\nsecond line"));
});

it('writes compose-safe preview runtime env files for user values', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'PREVIEW_QUOTED_VALUE',
        'value' => 'preview quotes" \'$`',
        'is_preview' => true,
        'is_literal' => true,
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
        'pull_request_id' => 42,
    ]);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)
        ->toContain('PREVIEW_QUOTED_VALUE='.escapeComposeEnvFileValue('preview quotes" \'$`'));
});

it('writes compose-safe runtime env values for remote secret references', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'SECRET_TOKEN',
        'value' => '{{vault.API_TOKEN}}',
        'is_literal' => false,
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);

    $secret = "secret\$value'quoted\"";
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'remote_secrets_cache' => ['API_TOKEN' => $secret],
    ]);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)->toContain('SECRET_TOKEN='.escapeComposeEnvFileValue($secret));
});

it('keeps the old runtime env format for variables saved before exact escaping', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    foreach ([
        ['key' => 'LEGACY_PLAIN', 'value' => 'a\'b"c\\d', 'is_literal' => false],
        ['key' => 'LEGACY_LITERAL', 'value' => 'x$y', 'is_literal' => true],
    ] as $attributes) {
        $variable = createApplicationEnvironmentVariable($application, [...$attributes, 'is_runtime' => true, 'is_buildtime' => false]);
        $variable->uses_legacy_escaping = true;
        $variable->save();
    }
    createApplicationEnvironmentVariable($application, [
        'key' => 'EXACT_PLAIN',
        'value' => 'a\'b"c\\d',
        'is_literal' => false,
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)
        ->toContain('LEGACY_PLAIN=a\\\'b\\"c\\\\d')
        ->toContain("LEGACY_LITERAL='x\$y'")
        ->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true));
});

it('gives legacy compose variables the value that the compose file got before exact escaping', function (bool $rawCompose, string $expectedDefault) {
    $compose = <<<'YAML'
services:
  app:
    image: alpine
    environment:
      - TRUSTED_HOST_PATTERNS=${TRUSTED_HOST_PATTERNS:-}
      - PLAIN_REFERENCE=${PLAIN_REFERENCE}
YAML;
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => $compose,
        'docker_compose' => $compose,
    ]);
    $application->settings()->update(['is_raw_compose_deployment_enabled' => $rawCompose]);

    foreach (['TRUSTED_HOST_PATTERNS', 'PLAIN_REFERENCE'] as $key) {
        $variable = createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => '^example\\.com$', 'is_runtime' => true, 'is_buildtime' => false]);
        $variable->uses_legacy_escaping = true;
        $variable->save();
    }

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)
        ->toContain('TRUSTED_HOST_PATTERNS='.$expectedDefault)
        ->toContain('PLAIN_REFERENCE=^example\\\\.com$');
})->with([
    'parsed compose' => [false, '"^example\\\\.com$"'],
    'raw compose deployment' => [true, '^example\\\\.com$'],
]);

it('writes a readable runtime env line for legacy literal values with a single quote', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    $variable = createApplicationEnvironmentVariable($application, ['key' => 'LEGACY_QUOTE', 'value' => "it's", 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false]);
    $variable->uses_legacy_escaping = true;
    $variable->save();

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)->toContain('LEGACY_QUOTE="it\'s"');
});

it('writes a readable runtime env line for legacy multiline JSON values', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    $json = "{\n  \"type\": \"service_account\"\n}";

    $variable = createApplicationEnvironmentVariable($application, ['key' => 'GOOGLE_CREDENTIALS_JSON', 'value' => $json, 'is_multiline' => true, 'is_runtime' => true, 'is_buildtime' => false]);
    $variable->uses_legacy_escaping = true;
    $variable->save();

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)->toContain('GOOGLE_CREDENTIALS_JSON='.escapeComposeEnvFileValue($json));
});

it('keeps the old runtime env format for preview variables saved before exact escaping', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    $variable = createApplicationEnvironmentVariable($application, [
        'key' => 'LEGACY_PREVIEW',
        'value' => "array:'self'",
        'is_preview' => true,
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);
    $variable->uses_legacy_escaping = true;
    $variable->save();

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['pull_request_id' => 42]);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)->toContain("LEGACY_PREVIEW=array:\\'self\\'");
});

it('resolves exact runtime env values without loading each variable resource', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    foreach (['FIRST_EXACT', 'SECOND_EXACT'] as $key) {
        createApplicationEnvironmentVariable($application, [
            'key' => $key,
            'value' => "array:'self'",
            'is_runtime' => true,
            'is_buildtime' => false,
        ]);
    }

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    DB::enableQueryLog();
    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
    $applicationLookups = collect(DB::getQueryLog())
        ->filter(fn (array $query) => str($query['query'])->contains('from "applications"'));
    DB::disableQueryLog();

    expect($runtimeEnvs)->toContain('FIRST_EXACT='.escapeComposeEnvFileValue("array:'self'", allowInterpolation: true))
        ->and($applicationLookups)->toBeEmpty();
});

it('keeps dollars literal in runtime JSON values', function () {
    [$application, $server] = makeDeploymentControlVarFixture();

    createApplicationEnvironmentVariable($application, [
        'key' => 'JSON_CONFIG',
        'value' => '{"price":"$5"}',
        'is_runtime' => true,
        'is_buildtime' => false,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs)->toContain('JSON_CONFIG='.escapeComposeEnvFileValue('{"price":"$5"}'));
});
it('omits only unset Compose overrides from production and preview runtime files', function (int $pullRequestId) {
    Server::flushIdentityMap();
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => "services:\n  app:\n    environment:\n      INHERITED: \${INHERITED:-info}\n      EMPTY: \${EMPTY-default}\n",
    ]);
    $application->update(['docker_compose' => $application->docker_compose_raw]);
    foreach (['INHERITED' => null, 'EMPTY' => ''] as $key => $value) {
        createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value]);
        $application->environment_variables_preview()->where('key', $key)->firstOrFail()->update(['value' => $value]);
    }
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => $pullRequestId]);
    $variables = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($variables->contains(fn (string $line) => str_starts_with($line, 'INHERITED=')))->toBeFalse()
        ->and($variables->contains('EMPTY='.escapeComposeEnvFileValue('')))->toBeTrue();
})->with([0, 42]);

it('includes migrated null Compose overrides in production and preview runtime files', function (int $pullRequestId) {
    Server::flushIdentityMap();
    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => "services:\n  app:\n    environment:\n      LEGACY: \${LEGACY:-info}\n",
    ]);
    $application->update(['docker_compose' => $application->docker_compose_raw]);
    createApplicationEnvironmentVariable($application, ['key' => 'LEGACY', 'value' => null]);
    foreach ([$application->environment_variables(), $application->environment_variables_preview()] as $relation) {
        $relation->where('key', 'LEGACY')->firstOrFail()->forceFill(['preserve_compose_null' => true])->save();
    }
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => $pullRequestId]);

    expect(invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables')->contains('LEGACY='.escapeComposeEnvFileValue('')))->toBeTrue();
})->with([0, 42]);

it('passes the build secrets hash as a build arg so a changed secret value invalidates the Docker cache', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['dockerSecretsSupported' => true]);

    invokeDeploymentJobMethod($job, $reflection, 'generate_build_secrets', collect(['NEXT_PUBLIC_API_URL' => 'https://old.example.com']));
    $oldFlags = readDeploymentJobProperty($job, $reflection, 'build_secrets');

    invokeDeploymentJobMethod($job, $reflection, 'generate_build_secrets', collect(['NEXT_PUBLIC_API_URL' => 'https://new.example.com']));
    $newFlags = readDeploymentJobProperty($job, $reflection, 'build_secrets');

    $hashArg = fn (string $flags): ?string => preg_match("/--build-arg 'COOLIFY_BUILD_SECRETS_HASH=([a-f0-9]{64})'/", $flags, $matches) === 1 ? $matches[1] : null;

    expect($hashArg($oldFlags))->not->toBeNull()
        ->and($hashArg($newFlags))->not->toBeNull()
        ->and($hashArg($newFlags))->not->toBe($hashArg($oldFlags))
        ->and($newFlags)->toContain("--secret 'id=NEXT_PUBLIC_API_URL,env=NEXT_PUBLIC_API_URL'");
});

it('declares the build secrets hash ARG in every stage when build secrets are used', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'dockerSecretsSupported' => true,
        'env_args' => collect(['NEXT_PUBLIC_API_URL' => 'https://new.example.com']),
        'build_secrets' => '--secret id=NEXT_PUBLIC_API_URL,env=NEXT_PUBLIC_API_URL',
        'saved_outputs' => collect([
            'dockerfile_content' => "FROM node:22 AS build\nRUN npm run build\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html",
        ]),
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/Dockerfile');

    expect(explode("\n", $job->writtenDockerfile))->toBe([
        '# syntax=docker/dockerfile:1',
        'FROM node:22 AS build',
        'ARG COOLIFY_BUILD_SECRETS_HASH',
        'RUN --mount=type=secret,id=NEXT_PUBLIC_API_URL,env=NEXT_PUBLIC_API_URL npm run build',
        'FROM nginx:alpine',
        'ARG COOLIFY_BUILD_SECRETS_HASH',
        'COPY --from=build /app/dist /usr/share/nginx/html',
    ]);
});

it('passes the build secrets hash as a Compose build arg when build secrets are used', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']);
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'dockerSecretsSupported' => true,
        'env_args' => collect(['NEXT_PUBLIC_API_URL' => 'https://new.example.com']),
    ]);

    $composeFile = invokeDeploymentJobMethod($job, $reflection, 'add_build_secrets_to_compose', [
        'services' => [
            'web' => ['build' => '.'],
            'api' => ['build' => ['context' => './api', 'args' => ['NODE_ENV' => 'production']]],
            'worker' => ['build' => ['context' => './worker', 'args' => ['NODE_ENV=production']]],
            'db' => ['image' => 'postgres:17'],
        ],
    ]);

    $hash = data_get($composeFile, 'services.web.build.args.COOLIFY_BUILD_SECRETS_HASH');

    expect($hash)->toMatch('/^[a-f0-9]{64}$/')
        ->and(data_get($composeFile, 'services.api.build.args'))->toBe(['NODE_ENV' => 'production', 'COOLIFY_BUILD_SECRETS_HASH' => $hash])
        ->and(data_get($composeFile, 'services.worker.build.args'))->toBe(['NODE_ENV=production', "COOLIFY_BUILD_SECRETS_HASH={$hash}"])
        ->and(data_get($composeFile, 'services.db'))->toBe(['image' => 'postgres:17']);
});

it('keeps heredoc bodies unchanged when it adds build secrets to a Dockerfile', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
        'dockerSecretsSupported' => true,
        'env_args' => collect(['API_URL' => 'https://new.example.com']),
        'build_secrets' => '--secret id=API_URL,env=API_URL',
        'saved_outputs' => collect([
            'dockerfile_content' => "FROM python:3.13\nRUN <<EOF\nfrom app import build\nRUN-like line\nEOF\nRUN python -c 'print(1)'",
        ]),
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/Dockerfile');

    expect(explode("\n", $job->writtenDockerfile))->toBe([
        '# syntax=docker/dockerfile:1',
        'FROM python:3.13',
        'ARG COOLIFY_BUILD_SECRETS_HASH',
        'RUN --mount=type=secret,id=API_URL,env=API_URL <<EOF',
        'from app import build',
        'RUN-like line',
        'EOF',
        "RUN --mount=type=secret,id=API_URL,env=API_URL python -c 'print(1)'",
    ]);
});

it('warns in the deployment log when PORT does not match ports_exposes', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['ports_exposes' => '80']);
    createApplicationEnvironmentVariable($application, ['key' => 'PORT', 'value' => '3000']);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);
    invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect(collect($job->recordedLogEntries)->contains(fn (string $entry) => str_contains($entry, 'PORT environment variable (3000)')))->toBeTrue();
});

it('does not warn about a PORT mismatch when readonly labels are disabled', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['ports_exposes' => '80']);
    $application->settings()->update(['is_container_label_readonly_enabled' => false]);
    createApplicationEnvironmentVariable($application, ['key' => 'PORT', 'value' => '3000']);

    [$job, $reflection] = makeControlVarFilteringJob($application, $server);
    invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect(collect($job->recordedLogEntries)->contains(fn (string $entry) => str_contains($entry, 'PORT environment variable')))->toBeFalse();
});

it('gives a preview from before v4.5.1 the production values it had', function () {
    [$application, $server] = makeDeploymentControlVarFixture();
    $application->settings()->update(['is_pr_deployments_public_enabled' => true]);

    createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'production-secret', 'is_buildtime' => false]);
    createApplicationEnvironmentVariable($application, ['key' => 'APP_NAME', 'value' => 'coolify', 'is_buildtime' => false]);
    $application->environment_variables_preview()->where('key', 'APP_NAME')->delete();

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
        'pull_request_id' => 99,
        'remote_secrets_cache' => [],
        'preview' => ApplicationPreview::create([
            'application_id' => $application->id,
            'pull_request_id' => 99,
            'pull_request_html_url' => 'https://example.com/pr/99',
            'keeps_production_values' => true,
        ]),
    ]);

    /** @var Collection $runtimeEnvs */
    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');

    expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('DB_PASSWORD=') && str($env)->contains('production-secret')))->toBeTrue()
        ->and($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=') && str($env)->contains('coolify')))->toBeTrue();
});

it('lets the Compose build interpolate runtime-only variables without passing them to the build', function (int $pullRequestId) {
    $compose = <<<'YAML'
services:
  app:
    build:
      context: .
      args:
        MAPPED_ARG: '${RUNTIME_IN_BUILD_ARGS}'
        LISTED_FROM_ENV: null
    environment:
      FERNET_KEY: '${FERNET_KEY:?}'
      OPTIONAL_REQUIRED: '${OPTIONAL_REQUIRED?}'
      BUILD_VALUE: '${BUILD_VALUE}'
  worker:
    build:
      context: ./worker
      args:
        - LISTED_ARG
    environment:
      FERNET_KEY: '${FERNET_KEY:?}'
YAML;

    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => $compose,
        'docker_compose' => $compose,
        'docker_compose_domains' => '[]',
    ]);
    $application->settings()->update(['inject_build_args_to_dockerfile' => false]);

    createApplicationEnvironmentVariable($application, ['key' => 'FERNET_KEY', 'value' => 'runtime-fernet-key', 'is_buildtime' => false]);
    createApplicationEnvironmentVariable($application, ['key' => 'OPTIONAL_REQUIRED', 'value' => '', 'is_buildtime' => false]);
    createApplicationEnvironmentVariable($application, ['key' => 'RUNTIME_IN_BUILD_ARGS', 'value' => 'runtime-mapped-arg', 'is_buildtime' => false]);
    createApplicationEnvironmentVariable($application, ['key' => 'LISTED_FROM_ENV', 'value' => 'runtime-null-arg', 'is_buildtime' => false]);
    createApplicationEnvironmentVariable($application, ['key' => 'LISTED_ARG', 'value' => 'runtime-listed-arg', 'is_buildtime' => false]);
    createApplicationEnvironmentVariable($application, ['key' => 'BUILD_VALUE', 'value' => 'build-value']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
        'pull_request_id' => $pullRequestId,
        'remote_secrets_cache' => [],
        'force_rebuild' => false,
        'docker_compose_location' => '/docker-compose.yaml',
    ]);

    invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables');
    $buildEnvironment = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
    invokeDeploymentJobMethod($job, $reflection, 'build_docker_compose_images', parseDockerComposeYaml($compose), $buildEnvironment);

    $buildEnvFile = $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_PATH];
    $buildCommand = collect($job->recordedCommands)->flatten()->first(fn (mixed $command): bool => is_string($command) && str_contains($command, ' build --pull'));
    $buildArgs = readDeploymentJobProperty($job, $reflection, 'build_args')->implode(' ');

    expect($buildEnvFile)->toContain('FERNET_KEY="runtime-fernet-key"')
        ->toContain('OPTIONAL_REQUIRED=""')
        ->toContain('BUILD_VALUE=')
        ->not->toContain('runtime-mapped-arg')
        ->not->toContain('runtime-null-arg')
        ->not->toContain('runtime-listed-arg')
        ->and($buildCommand)->toContain('--env-file '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH)
        ->and($buildArgs)->toContain('BUILD_VALUE')
        ->not->toContain('FERNET_KEY')
        ->not->toContain('runtime-');
})->with([
    'production' => 0,
    'preview' => 42,
]);

it('passes port-suffixed compose service URL variables to production and preview deployments', function (int $pullRequestId) {
    $compose = <<<'YAML'
services:
  docmost:
    build: .
    environment:
      - SERVICE_URL_DOCMOST_3000
      - APP_URL=$SERVICE_URL_DOCMOST_3000
YAML;

    [$application, $server] = makeDeploymentControlVarFixture([
        'build_pack' => 'dockercompose',
        'docker_compose_raw' => $compose,
        'docker_compose' => $compose,
        'docker_compose_domains' => json_encode(['docmost' => ['domain' => 'https://docs.example.com']]),
    ]);

    foreach ([
        'SERVICE_URL_DOCMOST' => 'https://docs.example.com',
        'SERVICE_FQDN_DOCMOST' => 'docs.example.com',
        'SERVICE_URL_DOCMOST_3000' => 'https://stale.example.com:3000',
        'SERVICE_FQDN_DOCMOST_3000' => 'stale.example.com:3000',
    ] as $key => $value) {
        createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value, 'is_runtime' => true, 'is_buildtime' => true]);
    }

    $overrides = ['pull_request_id' => $pullRequestId];
    $expectedHost = 'docs.example.com';
    if ($pullRequestId !== 0) {
        $expectedHost = '7.docs.example.com';
        $overrides['preview'] = ApplicationPreview::create([
            'application_id' => $application->id,
            'pull_request_id' => $pullRequestId,
            'pull_request_html_url' => 'https://github.com/example/repo/pull/7',
            'docker_compose_domains' => json_encode(['docmost' => ['domain' => "https://{$expectedHost}"]]),
        ]);
    }

    [$job, $reflection] = makeControlVarFilteringJob($application, $server, $overrides);

    $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
    expect($runtimeEnvs)
        ->toContain("SERVICE_URL_DOCMOST=https://{$expectedHost}")
        ->toContain("SERVICE_URL_DOCMOST_3000=https://{$expectedHost}:3000")
        ->toContain("SERVICE_FQDN_DOCMOST_3000={$expectedHost}:3000")
        ->and($runtimeEnvs->implode("\n"))->not->toContain('stale.example.com');

    $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')->implode("\n");
    expect($buildtimeEnvs)
        ->toContain("SERVICE_URL_DOCMOST_3000='https://{$expectedHost}:3000'")
        ->toContain("SERVICE_FQDN_DOCMOST_3000='{$expectedHost}:3000'")
        ->not->toContain('stale.example.com');
})->with([
    'production' => [0],
    'preview' => [7],
]);

it('hides the build secret values of the helper container from commands that start Compose services', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']);
    $application->settings()->update(['use_build_secrets' => true]);
    createApplicationEnvironmentVariable($application, [
        'key' => 'DATABASE_URL',
        'value' => 'postgresql://user:pass@db:5432/app',
        'is_literal' => true,
    ]);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']);
    $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets');
    expect($envFlags)->toBe("-e 'DATABASE_URL=postgresql://user:pass@db:5432/app'");

    // The helper container has the variable in its environment, as `docker run -e` gives it.
    $command = invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'echo "[${DATABASE_URL-unset}]"');
    $process = new Process(['bash', '-c', $command], env: ['DATABASE_URL' => "'postgresql://user:pass@db:5432/app'"]);
    $process->mustRun();

    expect(trim($process->getOutput()))->toBe('[unset]');
});

it('leaves commands unchanged when the helper container has no build secret values', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']);
    createApplicationEnvironmentVariable($application, ['key' => 'DATABASE_URL', 'value' => 'value']);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']);
    expect(invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'))->toBe('');

    expect(invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'docker compose up -d'))
        ->toBe('docker compose up -d');
});

it('gives build secrets the stored value without the quotes that Coolify adds', function () {
    [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerfile']);
    $application->settings()->update(['use_build_secrets' => true]);
    createApplicationEnvironmentVariable($application, ['key' => 'LITERAL', 'value' => "'quoted' value", 'is_literal' => true]);
    createApplicationEnvironmentVariable($application, ['key' => 'MULTILINE', 'value' => "line1\nline2", 'is_multiline' => true]);
    createApplicationEnvironmentVariable($application, ['key' => 'NORMAL', 'value' => "'kept'"]);

    [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockerfile']);
    $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets');

    // Read the values as the helper container gets them from `docker run -e`.
    $process = new Process(['bash', '-c', 'printf "%s\n" '.$envFlags.' | sed -n "s/^\\(LITERAL\\|MULTILINE\\|NORMAL\\)=/&/p"']);
    $process->mustRun();
    $output = $process->getOutput();

    expect($output)
        ->toContain("LITERAL='quoted' value\n")
        ->toContain("MULTILINE=line1\n")
        ->toContain("NORMAL='kept'\n");
});