From afbe4d6fd79ad6fbf70acc32e902ccc9932f0f0e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:13:23 +0200 Subject: [PATCH] feat(var): add environment variable copy functionality --- .../Shared/EnvironmentVariable/Show.php | 16 ++ .../EnvironmentVariable/ShowHardcoded.php | 19 +++ app/Models/EnvironmentVariable.php | 17 ++ .../shared/environment-variable/all.blade.php | 3 +- .../EnvironmentVariableCopyValueTest.php | 151 ++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/EnvironmentVariableCopyValueTest.php diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 7f37b1fc4d..633c8f04dc 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -161,6 +161,22 @@ class Show extends Component $this->valuesLoaded = true; } + public function copyValue(): ?string + { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + return null; + } + + if (! $this->env instanceof ModelsEnvironmentVariable) { + return $this->env->value; + } + + return $this->env->get_real_environment_variables_with_server( + $this->env->resolveReferencedValue(), + $this->env->resourceable, + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\EnvironmentVariable; use Livewire\Component; class ShowHardcoded extends Component @@ -20,6 +21,10 @@ class ShowHardcoded extends Component public bool $isPreview = false; + public ?string $resourceableType = null; + + public ?int $resourceableId = null; + public function mount() { $this->key = $this->env['key']; @@ -28,6 +33,20 @@ class ShowHardcoded extends Component $this->serviceName = $this->env['service_name'] ?? null; } + public function copyValue(): ?string + { + if (auth()->user()?->isMember() ?? true) { + return null; + } + + return EnvironmentVariable::make([ + 'value' => $this->value, + 'is_preview' => $this->isPreview, + 'resourceable_type' => $this->resourceableType, + 'resourceable_id' => $this->resourceableId, + ])->resolveReferencedValue(); + } + public function render() { return view('livewire.project.shared.environment-variable.show-hardcoded'); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..70c9013af2 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -302,6 +302,23 @@ class EnvironmentVariable extends BaseModel return $real_value; } + public function resolveReferencedValue(): ?string + { + $value = $this->value; + + if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { + return $value; + } + + $referencedKey = str($value)->after('$')->trim('{}')->value(); + + return static::where('resourceable_type', $this->resourceable_type) + ->where('resourceable_id', $this->resourceable_id) + ->where('is_preview', (bool) $this->is_preview) + ->where('key', $referencedKey) + ->first()?->value ?? $value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 923514efcc..87ecd69985 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -219,7 +219,8 @@ @else + :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" + :resourceableType="get_class($resource)" :resourceableId="$resource->id" /> @endif @endforeach diff --git a/tests/Feature/EnvironmentVariableCopyValueTest.php b/tests/Feature/EnvironmentVariableCopyValueTest.php new file mode 100644 index 0000000000..b12105ea84 --- /dev/null +++ b/tests/Feature/EnvironmentVariableCopyValueTest.php @@ -0,0 +1,151 @@ + 0]); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create(['environment_id' => $this->environment->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +function createEnvironmentVariable(array $attributes = []): EnvironmentVariable +{ + return EnvironmentVariable::create(array_merge([ + 'key' => 'API_KEY', + 'value' => 'secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => test()->application->id, + ], $attributes)); +} + +function assertCopiedValue(EnvironmentVariable|SharedEnvironmentVariable $env, ?string $expected): void +{ + Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->call('copyValue') + ->assertReturned($expected); +} + +function assertCopiedComposeValue(string $value, ?string $expected): void +{ + Livewire::test(ShowHardcoded::class, [ + 'env' => ['key' => 'MYSQL_USER', 'value' => $value], + 'resourceableType' => Application::class, + 'resourceableId' => test()->application->id, + ]) + ->call('copyValue') + ->assertReturned($expected); +} + +test('copies the plain value', function () { + assertCopiedValue(createEnvironmentVariable(), 'secret-value'); +}); + +test('copies the referenced variable value instead of the reference', function (string $reference) { + createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']); + + assertCopiedValue(createEnvironmentVariable(['key' => 'MYSQL_USER', 'value' => $reference]), 'classicpress-user'); +})->with(['bare' => '$SERVICE_USER_CLASSICPRESS', 'braced' => '${SERVICE_USER_CLASSICPRESS}']); + +test('copies the resolved shared variable value', function () { + SharedEnvironmentVariable::create([ + 'key' => 'MY_SECRET', + 'value' => 'resolved-secret', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + assertCopiedValue(createEnvironmentVariable(['value' => '{{team.MY_SECRET}}']), 'resolved-secret'); +}); + +test('copies embedded, literal and unknown references as stored', function () { + createEnvironmentVariable(['key' => 'SERVICE_PASSWORD_MYSQL', 'value' => 'generated-password']); + + assertCopiedValue( + createEnvironmentVariable(['key' => 'DATABASE_URL', 'value' => 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306']), + 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306', + ); + assertCopiedValue( + createEnvironmentVariable(['key' => 'LITERAL', 'value' => '$SERVICE_PASSWORD_MYSQL', 'is_literal' => true]), + '$SERVICE_PASSWORD_MYSQL', + ); + assertCopiedValue(createEnvironmentVariable(['key' => 'UNKNOWN', 'value' => '$DOES_NOT_EXIST']), '$DOES_NOT_EXIST'); +}); + +test('copies literal values without .env-style quoting', function () { + $env = createEnvironmentVariable(['value' => 'pa$$word', 'is_literal' => true]); + + expect($env->real_value)->toBe("'pa\$\$word'"); + assertCopiedValue($env, 'pa$$word'); +}); + +test('copies the value of a shared environment variable row', function () { + $shared = SharedEnvironmentVariable::create([ + 'key' => 'TEAM_WIDE', + 'value' => 'team-wide-value', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + assertCopiedValue($shared, 'team-wide-value'); +}); + +test('members get no copy button and no value', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(Show::class, ['env' => createEnvironmentVariable(), 'type' => 'application']) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); + +test('locked variables get no copy button and no value', function () { + Livewire::test(Show::class, ['env' => createEnvironmentVariable(['is_shown_once' => true]), 'type' => 'application']) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); + +test('compose-managed rows copy the referenced variable value', function () { + createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']); + + assertCopiedComposeValue('$SERVICE_USER_CLASSICPRESS', 'classicpress-user'); + assertCopiedComposeValue('production', 'production'); +}); + +test('compose-managed rows hide copying from members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(ShowHardcoded::class, [ + 'env' => ['key' => 'MYSQL_USER', 'value' => '$SERVICE_USER_CLASSICPRESS'], + 'resourceableType' => Application::class, + 'resourceableId' => $this->application->id, + ]) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +});