feat: add environment variable autocomplete component (#7282)
This commit is contained in:
commit
17f4b126b1
6 changed files with 562 additions and 4 deletions
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
|
|
@ -56,6 +59,72 @@ public function mount()
|
|||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function availableSharedVariables(): array
|
||||
{
|
||||
$team = currentTeam();
|
||||
$result = [
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
];
|
||||
|
||||
// Early return if no team
|
||||
if (! $team) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check if user can view team variables
|
||||
try {
|
||||
$this->authorize('view', $team);
|
||||
$result['team'] = $team->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view team variables
|
||||
}
|
||||
|
||||
// Get project variables if we have a project_uuid in route
|
||||
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||
if ($projectUuid) {
|
||||
$project = Project::where('team_id', $team->id)
|
||||
->where('uuid', $projectUuid)
|
||||
->first();
|
||||
|
||||
if ($project) {
|
||||
try {
|
||||
$this->authorize('view', $project);
|
||||
$result['project'] = $project->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
|
||||
// Get environment variables if we have an environment_uuid in route
|
||||
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||
if ($environmentUuid) {
|
||||
$environment = $project->environments()
|
||||
->where('uuid', $environmentUuid)
|
||||
->first();
|
||||
|
||||
if ($environment) {
|
||||
try {
|
||||
$this->authorize('view', $environment);
|
||||
$result['environment'] = $environment->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view environment variables
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view project variables
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->validate();
|
||||
|
|
|
|||
89
app/View/Components/Forms/EnvVarInput.php
Normal file
89
app/View/Components/Forms/EnvVarInput.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\View\Components\Forms;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\View\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class EnvVarInput extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
public array $scopeUrls = [];
|
||||
|
||||
public function __construct(
|
||||
public ?string $id = null,
|
||||
public ?string $name = null,
|
||||
public ?string $type = 'text',
|
||||
public ?string $value = null,
|
||||
public ?string $label = null,
|
||||
public bool $required = false,
|
||||
public bool $disabled = false,
|
||||
public bool $readonly = false,
|
||||
public ?string $helper = null,
|
||||
public string $defaultClass = 'input',
|
||||
public string $autocomplete = 'off',
|
||||
public ?int $minlength = null,
|
||||
public ?int $maxlength = null,
|
||||
public bool $autofocus = false,
|
||||
public ?string $canGate = null,
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
public array $availableVars = [],
|
||||
public ?string $projectUuid = null,
|
||||
public ?string $environmentUuid = null,
|
||||
) {
|
||||
// Handle authorization-based disabling
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||
|
||||
if (! $hasPermission) {
|
||||
$this->disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
|
||||
if (is_null($this->id)) {
|
||||
$this->id = new Cuid2;
|
||||
// Don't create wire:model binding for auto-generated IDs
|
||||
$this->modelBinding = 'null';
|
||||
}
|
||||
// Generate unique HTML ID by adding random suffix
|
||||
// This prevents duplicate IDs when multiple forms are on the same page
|
||||
if ($this->modelBinding && $this->modelBinding !== 'null') {
|
||||
// Use original ID with random suffix for uniqueness
|
||||
$uniqueSuffix = new Cuid2;
|
||||
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
|
||||
} else {
|
||||
$this->htmlId = (string) $this->id;
|
||||
}
|
||||
|
||||
if (is_null($this->name)) {
|
||||
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
|
||||
}
|
||||
|
||||
$this->scopeUrls = [
|
||||
'team' => route('shared-variables.team.index'),
|
||||
'project' => route('shared-variables.project.index'),
|
||||
'environment' => $this->projectUuid && $this->environmentUuid
|
||||
? route('shared-variables.environment.show', [
|
||||
'project_uuid' => $this->projectUuid,
|
||||
'environment_uuid' => $this->environmentUuid,
|
||||
])
|
||||
: route('shared-variables.environment.index'),
|
||||
'default' => route('shared-variables.index'),
|
||||
];
|
||||
|
||||
return view('components.forms.env-var-input');
|
||||
}
|
||||
}
|
||||
268
resources/views/components/forms/env-var-input.blade.php
Normal file
268
resources/views/components/forms/env-var-input.blade.php
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<div class="w-full">
|
||||
@if ($label)
|
||||
<label class="flex gap-1 items-center mb-1 text-sm font-medium">{{ $label }}
|
||||
@if ($required)
|
||||
<x-highlighted text="*" />
|
||||
@endif
|
||||
@if ($helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
</label>
|
||||
@endif
|
||||
|
||||
<div x-data="{
|
||||
showDropdown: false,
|
||||
suggestions: [],
|
||||
selectedIndex: 0,
|
||||
cursorPosition: 0,
|
||||
currentScope: null,
|
||||
availableScopes: ['team', 'project', 'environment'],
|
||||
availableVars: @js($availableVars),
|
||||
scopeUrls: @js($scopeUrls),
|
||||
|
||||
isAutocompleteDisabled() {
|
||||
const hasAnyVars = Object.values(this.availableVars).some(vars => vars.length > 0);
|
||||
return !hasAnyVars;
|
||||
},
|
||||
|
||||
handleInput() {
|
||||
const input = this.$refs.input;
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value || '';
|
||||
|
||||
if (this.isAutocompleteDisabled()) {
|
||||
this.showDropdown = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.cursorPosition = input.selectionStart || 0;
|
||||
const textBeforeCursor = value.substring(0, this.cursorPosition);
|
||||
|
||||
const openBraces = '{' + '{';
|
||||
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
|
||||
|
||||
if (lastBraceIndex === -1) {
|
||||
this.showDropdown = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastBraceIndex > 0 && textBeforeCursor[lastBraceIndex - 1] === '{') {
|
||||
this.showDropdown = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const textAfterBrace = textBeforeCursor.substring(lastBraceIndex);
|
||||
const closeBraces = '}' + '}';
|
||||
if (textAfterBrace.includes(closeBraces)) {
|
||||
this.showDropdown = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const content = textAfterBrace.substring(2).trim();
|
||||
|
||||
if (content === '') {
|
||||
this.currentScope = null;
|
||||
this.suggestions = this.availableScopes.map(scope => ({
|
||||
type: 'scope',
|
||||
value: scope,
|
||||
display: scope
|
||||
}));
|
||||
this.selectedIndex = 0;
|
||||
this.showDropdown = true;
|
||||
} else if (content.includes('.')) {
|
||||
const [scope, partial] = content.split('.');
|
||||
|
||||
if (!this.availableScopes.includes(scope)) {
|
||||
this.showDropdown = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentScope = scope;
|
||||
const scopeVars = this.availableVars[scope] || [];
|
||||
const filtered = scopeVars.filter(v =>
|
||||
v.toLowerCase().includes((partial || '').toLowerCase())
|
||||
);
|
||||
|
||||
if (filtered.length === 0 && scopeVars.length === 0) {
|
||||
this.suggestions = [];
|
||||
this.showDropdown = true;
|
||||
} else {
|
||||
this.suggestions = filtered.map(varName => ({
|
||||
type: 'variable',
|
||||
value: varName,
|
||||
display: `${scope}.${varName}`,
|
||||
scope: scope
|
||||
}));
|
||||
this.selectedIndex = 0;
|
||||
this.showDropdown = filtered.length > 0;
|
||||
}
|
||||
} else {
|
||||
this.currentScope = null;
|
||||
const filtered = this.availableScopes.filter(scope =>
|
||||
scope.toLowerCase().includes(content.toLowerCase())
|
||||
);
|
||||
|
||||
this.suggestions = filtered.map(scope => ({
|
||||
type: 'scope',
|
||||
value: scope,
|
||||
display: scope
|
||||
}));
|
||||
this.selectedIndex = 0;
|
||||
this.showDropdown = filtered.length > 0;
|
||||
}
|
||||
},
|
||||
|
||||
getScopeUrl(scope) {
|
||||
return this.scopeUrls[scope] || this.scopeUrls['default'];
|
||||
},
|
||||
|
||||
selectSuggestion(suggestion) {
|
||||
const input = this.$refs.input;
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value || '';
|
||||
const textBeforeCursor = value.substring(0, this.cursorPosition);
|
||||
const textAfterCursor = value.substring(this.cursorPosition);
|
||||
const openBraces = '{' + '{';
|
||||
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
|
||||
|
||||
if (suggestion.type === 'scope') {
|
||||
const newText = value.substring(0, lastBraceIndex) +
|
||||
openBraces + ' ' + suggestion.value + '.' +
|
||||
textAfterCursor;
|
||||
input.value = newText;
|
||||
this.cursorPosition = lastBraceIndex + 3 + suggestion.value.length + 1;
|
||||
|
||||
this.$nextTick(() => {
|
||||
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
|
||||
input.focus();
|
||||
this.handleInput();
|
||||
});
|
||||
} else {
|
||||
const closeBraces = '}' + '}';
|
||||
const newText = value.substring(0, lastBraceIndex) +
|
||||
openBraces + ' ' + suggestion.display + ' ' + closeBraces +
|
||||
textAfterCursor;
|
||||
input.value = newText;
|
||||
this.cursorPosition = lastBraceIndex + 3 + suggestion.display.length + 3;
|
||||
|
||||
input.dispatchEvent(new Event('input'));
|
||||
|
||||
this.showDropdown = false;
|
||||
this.selectedIndex = 0;
|
||||
|
||||
this.$nextTick(() => {
|
||||
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleKeydown(event) {
|
||||
if (!this.showDropdown) return;
|
||||
if (!this.suggestions || this.suggestions.length === 0) return;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.suggestions.length;
|
||||
this.$nextTick(() => {
|
||||
const el = document.getElementById('suggestion-' + this.selectedIndex);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = this.selectedIndex <= 0 ? this.suggestions.length - 1 : this.selectedIndex - 1;
|
||||
this.$nextTick(() => {
|
||||
const el = document.getElementById('suggestion-' + this.selectedIndex);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
} else if (event.key === 'Enter' && this.showDropdown) {
|
||||
event.preventDefault();
|
||||
if (this.suggestions[this.selectedIndex]) {
|
||||
this.selectSuggestion(this.suggestions[this.selectedIndex]);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
this.showDropdown = false;
|
||||
}
|
||||
}
|
||||
}"
|
||||
@click.outside="showDropdown = false"
|
||||
class="relative">
|
||||
|
||||
<input
|
||||
x-ref="input"
|
||||
@input="handleInput()"
|
||||
@keydown="handleKeydown($event)"
|
||||
@click="handleInput()"
|
||||
autocomplete="{{ $autocomplete }}"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@required($required)
|
||||
@readonly($readonly)
|
||||
@if ($modelBinding !== 'null')
|
||||
wire:model="{{ $modelBinding }}"
|
||||
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
|
||||
@endif
|
||||
wire:loading.attr="disabled"
|
||||
type="{{ $type }}"
|
||||
@disabled($disabled)
|
||||
@if ($htmlId !== 'null') id={{ $htmlId }} @endif
|
||||
name="{{ $name }}"
|
||||
placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) autofocus @endif>
|
||||
|
||||
{{-- Dropdown for suggestions --}}
|
||||
<div x-show="showDropdown"
|
||||
x-transition
|
||||
class="absolute z-[60] w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg">
|
||||
|
||||
<template x-if="suggestions.length === 0 && currentScope">
|
||||
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
|
||||
<a :href="getScopeUrl(currentScope)"
|
||||
class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block"
|
||||
target="_blank">
|
||||
Add <span x-text="currentScope"></span> variables →
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="suggestions.length > 0"
|
||||
x-ref="dropdownList"
|
||||
class="max-h-48 overflow-y-scroll"
|
||||
style="scrollbar-width: thin;">
|
||||
<template x-for="(suggestion, index) in suggestions" :key="index">
|
||||
<div :id="'suggestion-' + index"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-2"
|
||||
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': index === selectedIndex }">
|
||||
<template x-if="suggestion.type === 'scope'">
|
||||
<span class="text-xs px-2 py-0.5 bg-coollabs/10 dark:bg-warning/10 text-coollabs dark:text-warning rounded">
|
||||
SCOPE
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="suggestion.type === 'variable'">
|
||||
<span class="text-xs px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded">
|
||||
VAR
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-sm font-mono" x-text="suggestion.display"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!$label && $helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
@error($modelBinding)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
@enderror
|
||||
</div>
|
||||
|
|
@ -1,8 +1,20 @@
|
|||
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submit'>
|
||||
<x-forms.input placeholder="NODE_ENV" id="key" label="Name" required />
|
||||
<x-forms.textarea x-show="$wire.is_multiline === true" x-cloak id="value" label="Value" required />
|
||||
<x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value"
|
||||
x-bind:label="$wire.is_multiline === false && 'Value'" required />
|
||||
@if ($is_multiline)
|
||||
<x-forms.textarea id="value" label="Value" required />
|
||||
@else
|
||||
<x-forms.env-var-input placeholder="production" id="value" label="Value" required
|
||||
:availableVars="$shared ? [] : $this->availableSharedVariables"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')" />
|
||||
@endif
|
||||
|
||||
@if (!$shared && !$is_multiline)
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400 -mt-1">
|
||||
Tip: Type <span class="font-mono dark:text-warning text-coollabs">{{</span> to reference a shared environment
|
||||
variable
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (!$shared)
|
||||
<x-forms.checkbox id="is_buildtime"
|
||||
|
|
@ -22,4 +34,4 @@
|
|||
<x-forms.button type="submit" @click="slideOverOpen=false">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</form>
|
||||
</form>
|
||||
67
tests/Unit/EnvVarInputComponentTest.php
Normal file
67
tests/Unit/EnvVarInputComponentTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
use App\View\Components\Forms\EnvVarInput;
|
||||
|
||||
it('renders with default properties', function () {
|
||||
$component = new EnvVarInput;
|
||||
|
||||
expect($component->required)->toBeFalse()
|
||||
->and($component->disabled)->toBeFalse()
|
||||
->and($component->readonly)->toBeFalse()
|
||||
->and($component->defaultClass)->toBe('input')
|
||||
->and($component->availableVars)->toBe([]);
|
||||
});
|
||||
|
||||
it('uses provided id', function () {
|
||||
$component = new EnvVarInput(id: 'env-key');
|
||||
|
||||
expect($component->id)->toBe('env-key');
|
||||
});
|
||||
|
||||
it('accepts available vars', function () {
|
||||
$vars = [
|
||||
'team' => ['DATABASE_URL', 'API_KEY'],
|
||||
'project' => ['STRIPE_KEY'],
|
||||
'environment' => ['DEBUG'],
|
||||
];
|
||||
|
||||
$component = new EnvVarInput(availableVars: $vars);
|
||||
|
||||
expect($component->availableVars)->toBe($vars);
|
||||
});
|
||||
|
||||
it('accepts required parameter', function () {
|
||||
$component = new EnvVarInput(required: true);
|
||||
|
||||
expect($component->required)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts disabled state', function () {
|
||||
$component = new EnvVarInput(disabled: true);
|
||||
|
||||
expect($component->disabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts readonly state', function () {
|
||||
$component = new EnvVarInput(readonly: true);
|
||||
|
||||
expect($component->readonly)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts autofocus parameter', function () {
|
||||
$component = new EnvVarInput(autofocus: true);
|
||||
|
||||
expect($component->autofocus)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts authorization properties', function () {
|
||||
$component = new EnvVarInput(
|
||||
canGate: 'update',
|
||||
canResource: 'resource',
|
||||
autoDisable: false
|
||||
);
|
||||
|
||||
expect($component->canGate)->toBe('update')
|
||||
->and($component->canResource)->toBe('resource')
|
||||
->and($component->autoDisable)->toBeFalse();
|
||||
});
|
||||
53
tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php
Normal file
53
tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
it('has availableSharedVariables computed property', function () {
|
||||
$component = new Add;
|
||||
|
||||
// Check that the method exists
|
||||
expect(method_exists($component, 'availableSharedVariables'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('component has required properties for environment variable autocomplete', function () {
|
||||
$component = new Add;
|
||||
|
||||
expect($component)->toHaveProperty('key')
|
||||
->and($component)->toHaveProperty('value')
|
||||
->and($component)->toHaveProperty('is_multiline')
|
||||
->and($component)->toHaveProperty('is_literal')
|
||||
->and($component)->toHaveProperty('is_runtime')
|
||||
->and($component)->toHaveProperty('is_buildtime')
|
||||
->and($component)->toHaveProperty('parameters');
|
||||
});
|
||||
|
||||
it('returns empty arrays when currentTeam returns null', function () {
|
||||
// Mock Auth facade to return null for user
|
||||
Auth::shouldReceive('user')
|
||||
->andReturn(null);
|
||||
|
||||
$component = new Add;
|
||||
$component->parameters = [];
|
||||
|
||||
$result = $component->availableSharedVariables();
|
||||
|
||||
expect($result)->toBe([
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
it('availableSharedVariables method wraps authorization checks in try-catch blocks', function () {
|
||||
// Read the source code to verify the authorization pattern
|
||||
$reflectionMethod = new ReflectionMethod(Add::class, 'availableSharedVariables');
|
||||
$source = file_get_contents($reflectionMethod->getFileName());
|
||||
|
||||
// Verify that the method contains authorization checks
|
||||
expect($source)->toContain('$this->authorize(\'view\', $team)')
|
||||
->and($source)->toContain('$this->authorize(\'view\', $project)')
|
||||
->and($source)->toContain('$this->authorize(\'view\', $environment)')
|
||||
// Verify authorization checks are wrapped in try-catch blocks
|
||||
->and($source)->toContain('} catch (\Illuminate\Auth\Access\AuthorizationException $e) {');
|
||||
});
|
||||
Loading…
Reference in a new issue