diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 53b55009e..a19837e16 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -7,6 +7,8 @@ use App\Models\EnvironmentVariable; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Livewire\Component; class All extends Component @@ -25,6 +27,8 @@ class All extends Component public string $view = 'normal'; + public string $search = ''; + public bool $is_env_sorting_enabled = false; public bool $use_build_secrets = false; @@ -35,6 +39,20 @@ class All extends Component 'environmentVariableDeleted' => 'refreshEnvs', ]; + public function updatedSearch(): void + { + $this->clearEnvironmentVariableCaches(); + } + + private function clearEnvironmentVariableCaches(): void + { + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); + unset($this->hardcodedEnvironmentVariables); + unset($this->hardcodedEnvironmentVariablesPreview); + unset($this->hasEnvironmentVariables); + } + public function mount() { $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); @@ -65,8 +83,27 @@ class All extends Component public function getEnvironmentVariablesProperty() { - $query = $this->resource->environment_variables() - ->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); + return $this->getEnvironmentVariables(false); + } + + public function getEnvironmentVariablesPreviewProperty() + { + return $this->getEnvironmentVariables(true); + } + + private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection + { + $query = $isPreview + ? $this->resource->environment_variables_preview() + : $this->resource->environment_variables(); + + $query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); + + if ($withSearch && $this->searchTerm() !== '') { + $escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\'); + + $query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']); + } if ($this->is_env_sorting_enabled) { $query->orderBy('key'); @@ -77,18 +114,22 @@ class All extends Component return $query->get(); } - public function getEnvironmentVariablesPreviewProperty() + private function searchTerm(): string { - $query = $this->resource->environment_variables_preview() - ->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); + return trim($this->search); + } - if ($this->is_env_sorting_enabled) { - $query->orderBy('key'); - } else { - $query->orderBy('order'); - } + public function getHasEnvironmentVariablesProperty(): bool + { + return $this->environmentVariables->isNotEmpty() || + $this->environmentVariablesPreview->isNotEmpty() || + $this->hardcodedEnvironmentVariables->isNotEmpty() || + $this->hardcodedEnvironmentVariablesPreview->isNotEmpty(); + } - return $query->get(); + public function getIsSearchActiveProperty(): bool + { + return $this->searchTerm() !== ''; } public function getHardcodedEnvironmentVariablesProperty() @@ -138,6 +179,12 @@ class All extends Component return ! in_array($var['key'], $managedKeys); }); + if ($this->searchTerm() !== '') { + $hardcodedVars = $hardcodedVars->filter(function ($var) { + return str($var['key'])->contains($this->searchTerm(), true); + }); + } + // Apply sorting based on is_env_sorting_enabled if ($this->is_env_sorting_enabled) { $hardcodedVars = $hardcodedVars->sortBy('key')->values(); @@ -149,9 +196,9 @@ class All extends Component public function getDevView() { - $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); + $this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false)); if ($this->showPreview) { - $this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview); + $this->variablesPreview = $this->formatEnvironmentVariables($this->getEnvironmentVariables(true, false)); } } @@ -282,9 +329,7 @@ class All extends Component $environment->order = $maxOrder + 1; $environment->save(); - // Clear computed property cache to force refresh - unset($this->environmentVariables); - unset($this->environmentVariablesPreview); + $this->clearEnvironmentVariableCaches(); $this->dispatch('success', 'Environment variable added.'); } @@ -413,9 +458,7 @@ class All extends Component public function refreshEnvs() { $this->resource->refresh(); - // Clear computed property cache to force refresh - unset($this->environmentVariables); - unset($this->environmentVariablesPreview); + $this->clearEnvironmentVariableCaches(); $this->getDevView(); } } 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 2ae3ad166..991327265 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -43,38 +43,69 @@ @endif @if ($view === 'normal') -
-

Production Environment Variables

-
Environment (secrets) variables for Production.
-
- @forelse ($this->environmentVariables as $env) - - @empty -
No environment variables found.
- @endforelse - @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty()) - @foreach ($this->hardcodedEnvironmentVariables as $index => $env) - - @endforeach - @endif - @if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview) -
-

Preview Deployments Environment Variables

-
Environment (secrets) variables for Preview Deployments.
+
+
+ +
+
+ + +
+
- @foreach ($this->environmentVariablesPreview as $env) - - @endforeach - @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty()) - @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env) - +
+ @if ($this->isSearchActive && ! $this->hasEnvironmentVariables) +
No environment variables found.
+ @else + @if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty()) +
+

Production Environment Variables

+
Environment (secrets) variables for Production.
+
+ @foreach ($this->environmentVariables as $env) + @endforeach + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariables as $index => $env) + + @endforeach + @endif + @endif + @if ( + $resource->type() === 'application' && + $showPreview && + ($this->environmentVariablesPreview->isNotEmpty() || $this->hardcodedEnvironmentVariablesPreview->isNotEmpty()) + ) +
+

Preview Deployments Environment Variables

+
Environment (secrets) variables for Preview Deployments.
+
+ @foreach ($this->environmentVariablesPreview as $env) + + @endforeach + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env) + + @endforeach + @endif @endif @endif @else @@ -88,8 +119,9 @@ label="Production Environment Variables"> @if ($showPreview) - + @endif Save All Environment Variables @@ -98,8 +130,9 @@ label="Production Environment Variables" disabled> @if ($showPreview) - + @endif @endcan diff --git a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php index e27fd0b4b..de97e6095 100644 --- a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php +++ b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php @@ -29,3 +29,28 @@ it('uses sans font for the developer bulk environment variable editor', function ->not->toContain('wire:model="variables" monospace') ->not->toContain('wire:model="variablesPreview" monospace'); }); + +it('renders the environment variable search field above the production title', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php')); + + expect(strpos($view, 'aria-label="Search environment variables"')) + ->toBeLessThan(strpos($view, '

Production Environment Variables

')); +}); + +it('renders a single no results message for empty environment variable searches', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php')); + + expect($view) + ->toContain('@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)') + ->toContain('
No environment variables found.
') + ->toContain('@else'); +}); + +it('only renders the production section when production variables are visible', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php')); + + expect($view) + ->toContain('@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())') + ->not->toContain('@forelse ($this->environmentVariables as $env)') + ->not->toContain('@empty'); +}); diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php new file mode 100644 index 000000000..613144d53 --- /dev/null +++ b/tests/Feature/EnvironmentVariableSearchTest.php @@ -0,0 +1,251 @@ + 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->actingAs($this->user); +}); + +it('filters production environment variables by key case-insensitively', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://example', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $application]) + ->set('search', 'api'); + + expect($component->instance()->environmentVariables->pluck('key')->all()) + ->toBe(['API_KEY']); +}); + +it('treats production environment variable search wildcards literally', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APIXKEY', + 'value' => 'other-secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'PERCENT%KEY', + 'value' => 'percent-secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $application]) + ->set('search', 'api_key'); + + expect($component->instance()->environmentVariables->pluck('key')->all()) + ->toBe(['API_KEY']); + + $component->set('search', '%KEY'); + + expect($component->instance()->environmentVariables->pluck('key')->all()) + ->toBe(['PERCENT%KEY']); +}); + +it('filters preview environment variables by key case-insensitively', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'PREVIEW_TOKEN', + 'value' => 'preview-secret', + 'is_preview' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'OTHER_PREVIEW_VALUE', + 'value' => 'preview-other', + 'is_preview' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $application]) + ->set('search', 'token'); + + expect($component->instance()->environmentVariablesPreview->pluck('key')->all()) + ->toBe(['PREVIEW_TOKEN']); +}); + +it('filters hardcoded Docker Compose environment variables by key case-insensitively', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + API_TOKEN: hardcoded-secret + DATABASE_URL: postgres://example +YAML, + ]); + + $component = Livewire::test(All::class, ['resource' => $service]) + ->set('search', 'api'); + + expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all()) + ->toBe(['API_TOKEN']); +}); + +it('does not show the empty production message when search only matches hardcoded variables', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + API_TOKEN: hardcoded-secret + DATABASE_URL: postgres://example +YAML, + ]); + + Livewire::test(All::class, ['resource' => $service]) + ->set('search', 'api') + ->assertSee('Production Environment Variables') + ->assertSee('API_TOKEN') + ->assertDontSee('No environment variables found.'); +}); + +it('keeps developer view unfiltered after searching', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://example', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $application]) + ->set('search', 'api') + ->call('switch') + ->assertSet('view', 'dev'); + + expect($component->get('variables')) + ->toContain('API_KEY=secret') + ->toContain('DATABASE_URL=postgres://example'); +}); + +it('does not delete non-matching variables when saving developer view after searching', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://example', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + Livewire::test(All::class, ['resource' => $application]) + ->set('search', 'api') + ->call('switch') + ->call('submit'); + + expect($application->environment_variables()->pluck('key')->all()) + ->toContain('API_KEY') + ->toContain('DATABASE_URL'); +}); + +it('hides the preview section when search filters out all preview variables', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $application->environment_variables_preview()->where('key', 'API_KEY')->delete(); + + EnvironmentVariable::create([ + 'key' => 'PREVIEW_TOKEN', + 'value' => 'preview-secret', + 'is_preview' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + Livewire::test(All::class, ['resource' => $application]) + ->set('search', 'api') + ->assertSee('Production Environment Variables') + ->assertSee('API_KEY') + ->assertDontSee('Preview Deployments Environment Variables') + ->assertDontSee('PREVIEW_TOKEN'); +});