Files
coolify/tests/Feature/ComposePreviewMagicVariablesTest.php
Andras Bacsai 2dcf8eeca3 fix(previews): keep production values for previews from before the upgrade
Public pull request previews no longer get production values, and no
preview falls back to production variables. Existing previews were
deployed with these values; for example, their databases were
initialized with the production password, so they broke on redeploy.

Previews that exist at the upgrade get keeps_production_values and
keep the earlier behavior. Previews created later do not. Server
transfer moves the flag.
2026-10-28 12:10:27 +01:00

239 lines
9.9 KiB
PHP

<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'build_pack' => 'dockercompose',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => <<<'YAML'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${SERVICE_USER_DB}
POSTGRES_PASSWORD: ${SERVICE_PASSWORD_DB}
YAML,
]);
});
function composePreviewMagicParse(Application $application, int $pullRequestId = 0): void
{
$previewId = null;
if ($pullRequestId !== 0) {
$previewId = ApplicationPreview::query()->firstOrCreate([
'application_id' => $application->id,
'pull_request_id' => $pullRequestId,
], [
'pull_request_html_url' => "https://github.com/example/repository/pull/{$pullRequestId}",
])->id;
}
$application->fresh()->parse(pull_request_id: $pullRequestId, preview_id: $previewId);
}
/**
* @return array{production: array<string, ?string>, preview: array<string, ?string>}
*/
function composePreviewMagicValues(Application $application): array
{
$keys = ['SERVICE_PASSWORD_DB', 'SERVICE_USER_DB'];
return [
'production' => $application->environment_variables()->whereIn('key', $keys)->pluck('value', 'key')->sortKeys()->all(),
'preview' => $application->environment_variables_preview()->whereIn('key', $keys)->pluck('value', 'key')->sortKeys()->all(),
];
}
it('generates the magic variables of a first pull request parse only in the preview variables', function () {
composePreviewMagicParse($this->application, 7);
$values = composePreviewMagicValues($this->application);
expect($values['production'])->toBe([])
->and(array_keys($values['preview']))->toBe(['SERVICE_PASSWORD_DB', 'SERVICE_USER_DB'])
->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBeEmpty();
// The production deployment generates its own values and keeps the preview values.
composePreviewMagicParse($this->application);
$afterProduction = composePreviewMagicValues($this->application);
expect($afterProduction['preview'])->toBe($values['preview'])
->and($afterProduction['production']['SERVICE_PASSWORD_DB'])->not->toBeEmpty()
->and($afterProduction['production']['SERVICE_PASSWORD_DB'])->not->toBe($values['preview']['SERVICE_PASSWORD_DB']);
});
it('does not copy a generated production magic secret to the preview variables', function (string $key) {
EnvironmentVariable::create([
'key' => $key,
'value' => 'production-secret',
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
expect($this->application->environment_variables_preview()->where('key', $key)->exists())->toBeFalse();
})->with([
'SERVICE_PASSWORD_DB',
'SERVICE_PASSWORD_64_DB',
'SERVICE_PASSWORDWITHSYMBOLS_DB',
'SERVICE_USER_DB',
'SERVICE_LOWERCASEUSER_DB',
'SERVICE_BASE64_DB',
'SERVICE_BASE64_64_DB',
'SERVICE_BASE64_128_DB',
'SERVICE_REALBASE64_DB',
'SERVICE_HEX_32_DB',
]);
it('still copies a normal or service url production variable to the preview variables', function (string $key) {
EnvironmentVariable::create([
'key' => $key,
'value' => 'production-value',
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
expect($this->application->environment_variables_preview()->where('key', $key)->value('value'))->toBe('production-value');
})->with(['DATABASE_HOST', 'SERVICE_URL_DB', 'SERVICE_FQDN_DB', 'SERVICE_PASSWORD_DB_MAIN']);
it('gives a private pull request its own magic values instead of the production values', function () {
composePreviewMagicParse($this->application);
$production = composePreviewMagicValues($this->application)['production'];
composePreviewMagicParse($this->application, 7);
$values = composePreviewMagicValues($this->application);
expect($values['production'])->toBe($production)
->and(array_keys($values['preview']))->toBe(['SERVICE_PASSWORD_DB', 'SERVICE_USER_DB'])
->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBeEmpty()
->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBe($production['SERVICE_PASSWORD_DB'])
->and($values['preview']['SERVICE_USER_DB'])->not->toBe($production['SERVICE_USER_DB']);
// The next pull request parse keeps the preview values, so the preview database keeps its password.
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application))->toBe($values);
});
it('keeps an existing preview copy of a production magic value for a private pull request', function () {
composePreviewMagicParse($this->application);
$production = composePreviewMagicValues($this->application)['production'];
// A preview copy that an earlier version created; existing preview databases use this password.
$this->application->environment_variables_preview()->create([
'key' => 'SERVICE_PASSWORD_DB',
'value' => $production['SERVICE_PASSWORD_DB'],
'is_preview' => true,
]);
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe($production['SERVICE_PASSWORD_DB']);
});
it('keeps a preview magic variable that the user created', function () {
EnvironmentVariable::create([
'key' => 'SERVICE_PASSWORD_DB',
'value' => 'user-preview-password',
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe('user-preview-password');
});
it('gives a public pull request its own magic values instead of the production values', function () {
composePreviewMagicParse($this->application);
$production = composePreviewMagicValues($this->application)['production'];
$this->application->settings->update(['is_pr_deployments_public_enabled' => true]);
composePreviewMagicParse($this->application, 7);
$values = composePreviewMagicValues($this->application);
expect($values['production'])->toBe($production)
->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBeEmpty()
->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBe($production['SERVICE_PASSWORD_DB'])
->and($values['preview']['SERVICE_USER_DB'])->not->toBe($production['SERVICE_USER_DB']);
// The next pull request parse keeps the preview values, so the preview database keeps its password.
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application))->toBe($values);
});
it('keeps a changed preview magic value of a public pull request', function () {
composePreviewMagicParse($this->application);
$this->application->settings->update(['is_pr_deployments_public_enabled' => true]);
$this->application->environment_variables_preview()->create([
'key' => 'SERVICE_PASSWORD_DB',
'value' => 'preview-password',
'is_preview' => true,
]);
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe('preview-password');
});
it('replaces an existing preview copy of a production magic value for a public pull request', function () {
composePreviewMagicParse($this->application);
$production = composePreviewMagicValues($this->application)['production'];
$this->application->environment_variables_preview()->create([
'key' => 'SERVICE_PASSWORD_DB',
'value' => $production['SERVICE_PASSWORD_DB'],
'is_preview' => true,
]);
$this->application->settings->update(['is_pr_deployments_public_enabled' => true]);
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])
->not->toBeEmpty()
->not->toBe($production['SERVICE_PASSWORD_DB']);
});
it('keeps the production magic value of a public pull request preview from before v4.5.1', function () {
composePreviewMagicParse($this->application);
$production = composePreviewMagicValues($this->application)['production'];
$this->application->environment_variables_preview()->create([
'key' => 'SERVICE_PASSWORD_DB',
'value' => $production['SERVICE_PASSWORD_DB'],
'is_preview' => true,
]);
ApplicationPreview::query()->create([
'application_id' => $this->application->id,
'pull_request_id' => 7,
'pull_request_html_url' => 'https://github.com/example/repository/pull/7',
'keeps_production_values' => true,
]);
$this->application->settings->update(['is_pr_deployments_public_enabled' => true]);
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe($production['SERVICE_PASSWORD_DB']);
});