From df166ac689194872a297e88e2036aa2628a74aab Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 22 May 2026 12:37:48 +0200 Subject: [PATCH] fix(environment): scope DeleteEnvironment lookups to current team Scope DeleteEnvironment::mount() and delete() lookups through Environment::ownedByCurrentTeam() so an environment_id that belongs to another team resolves to a 404 instead of loading the foreign record. Mark $environment_id as #[Locked] so the public Livewire property can no longer be reassigned from the client. Add tests/Feature/DeleteEnvironmentTeamScopingTest.php covering mount, delete, the #[Locked] guard, and the team-scoped helper for both the cross-team and own-team cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Livewire/Project/DeleteEnvironment.php | 12 ++- .../DeleteEnvironmentTeamScopingTest.php | 88 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/DeleteEnvironmentTeamScopingTest.php diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index aa6e95975..4d28c7676 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -4,12 +4,14 @@ use App\Models\Environment; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class DeleteEnvironment extends Component { use AuthorizesRequests; + #[Locked] public int $environment_id; public bool $disabled = false; @@ -20,12 +22,8 @@ class DeleteEnvironment extends Component public function mount() { - try { - $this->environmentName = Environment::findOrFail($this->environment_id)->name; - $this->parameters = get_route_parameters(); - } catch (\Exception $e) { - return handleError($e, $this); - } + $this->parameters = get_route_parameters(); + $this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name; } public function delete() @@ -33,7 +31,7 @@ public function delete() $this->validate([ 'environment_id' => 'required|int', ]); - $environment = Environment::findOrFail($this->environment_id); + $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id); $this->authorize('delete', $environment); if ($environment->isEmpty()) { diff --git a/tests/Feature/DeleteEnvironmentTeamScopingTest.php b/tests/Feature/DeleteEnvironmentTeamScopingTest.php new file mode 100644 index 000000000..0730c0984 --- /dev/null +++ b/tests/Feature/DeleteEnvironmentTeamScopingTest.php @@ -0,0 +1,88 @@ + InstanceSettings::query()->create(['id' => 0])); + + // Current team + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Another team + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('mount cannot load DeleteEnvironment with environment from another team', function () { + Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentB->id]); +})->throws(ModelNotFoundException::class); + +test('mount can load DeleteEnvironment with own team environment', function () { + $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]); + + expect($component->get('environmentName'))->toBe($this->environmentA->name); +}); + +test('environment_id is locked and cannot be reassigned from the client', function () { + $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]); + + try { + $component->set('environment_id', $this->environmentB->id); + $this->fail('Setting a #[Locked] property should have thrown.'); + } catch (CannotUpdateLockedPropertyException) { + expect(true)->toBeTrue(); + } +}); + +test('delete still removes an empty environment owned by the current team', function () { + $component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]) + ->set('parameters', ['project_uuid' => $this->projectA->uuid]); + + $component->call('delete'); + + expect(Environment::find($this->environmentA->id))->toBeNull(); +}); + +test('delete cannot resolve a non-empty environment from another team', function () { + // The team-scoped lookup must stay in the delete() path so the + // "has defined resources" branch can never run for an environment + // outside the caller's team. + Application::factory()->create([ + 'environment_id' => $this->environmentB->id, + ]); + + $teamScopedLookup = fn () => Environment::ownedByCurrentTeam() + ->findOrFail($this->environmentB->id); + + expect($teamScopedLookup)->toThrow(ModelNotFoundException::class); +}); + +test('team scoped lookup permits own team environment', function () { + // Positive case so the cross-team check above cannot pass merely + // because the helper itself is broken. + $found = Environment::ownedByCurrentTeam()->findOrFail($this->environmentA->id); + + expect($found->id)->toBe($this->environmentA->id); +});