From 94dfd6a54ec274f525766a97892852fb275b3401 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:38:04 +0100 Subject: [PATCH] fix(auth): enforce authorization checks in Livewire components - Replace manual ownership checks with authorize() in Destination/Show, NavbarDeleteTeam, and Project/Show - Add authorization checks for team deletion and environment creation - Add proper exception handling with try-catch blocks - Add comprehensive feature and browser tests for authorization scenarios - Update CLAUDE.md with Pest Browser Plugin testing guidelines --- CLAUDE.md | 37 +++ app/Livewire/Destination/Show.php | 14 +- app/Livewire/NavbarDeleteTeam.php | 46 +-- app/Livewire/Project/Show.php | 4 + tests/Feature/DestinationOwnershipTest.php | 60 ++++ .../NavbarDeleteTeamAuthorizationTest.php | 46 +++ .../ProjectEnvironmentAuthorizationTest.php | 55 ++++ tests/v4/Browser/AuthorizationTest.php | 263 ++++++++++++++++++ 8 files changed, 498 insertions(+), 27 deletions(-) create mode 100644 tests/Feature/DestinationOwnershipTest.php create mode 100644 tests/Feature/NavbarDeleteTeamAuthorizationTest.php create mode 100644 tests/Feature/ProjectEnvironmentAuthorizationTest.php create mode 100644 tests/v4/Browser/AuthorizationTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 8e398586b..46f781710 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,43 @@ # Frontend npm run build # production build ``` +## Browser Tests (Pest Browser Plugin) + +Uses `pestphp/pest-plugin-browser` with Laravel Dusk 8. New browser tests go in `tests/v4/Browser/`. + +```bash +# Run all browser tests +php artisan test --compact tests/v4/Browser/ + +# Run a specific browser test file +php artisan test --compact tests/v4/Browser/LoginTest.php + +# Run a specific test by name +php artisan test --compact --filter='can login with valid credentials' +``` + +### Writing Browser Tests + +- Place new tests in `tests/v4/Browser/` — legacy Dusk tests in `tests/Browser/` should not be used as reference. +- Use `RefreshDatabase` and seed required data (at minimum `InstanceSettings::create(['id' => 0])`) in `beforeEach`. +- Key API: `visit()`, `fill(field, value)`, `click(text)`, `assertSee()`, `assertDontSee()`, `assertPathIs()`, `screenshot()`. +- Always call `screenshot()` at the end of each test for debugging. +- For authenticated tests, create a helper function that logs in via the UI: + +```php +function loginAsRoot(): mixed +{ + return visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login'); +} +``` + +- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions. +- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`). +- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API. + ## Architecture ### Backend Structure (app/) diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 98cf72376..448a0d1bd 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -2,7 +2,6 @@ namespace App\Livewire\Destination; -use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -32,17 +31,12 @@ public function mount(string $destination_uuid) $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ?? SwarmDocker::whereUuid($destination_uuid)->firstOrFail(); - $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) { - if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) { - $this->destination = $destination; - $this->syncData(); - } - }); - if ($ownedByTeam === false) { - return redirect()->route('destination.index'); - } + $this->authorize('view', $destination); + $this->destination = $destination; $this->syncData(); + } catch (\Illuminate\Auth\Access\AuthorizationException) { + abort(403); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index a8c932912..78eafbeb4 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,12 +2,16 @@ namespace App\Livewire; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Livewire\Component; class NavbarDeleteTeam extends Component { + use AuthorizesRequests; + public $team; public function mount() @@ -17,27 +21,35 @@ public function mount() public function delete($password) { - if (! verifyPasswordConfirmation($password, $this)) { - return; - } - - $currentTeam = currentTeam(); - $currentTeam->delete(); - - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { + try { + if (! verifyPasswordConfirmation($password, $this)) { return; } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); - refreshSession(); + $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); - return redirectRoute($this, 'team.index'); + $currentTeam->members->each(function ($user) use ($currentTeam) { + if ($user->id === Auth::id()) { + return; + } + $user->teams()->detach($currentTeam); + $session = DB::table('sessions')->where('user_id', $user->id)->first(); + if ($session) { + DB::table('sessions')->where('id', $session->id)->delete(); + } + }); + + Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); + $currentTeam->delete(); + + $newTeam = Auth::user()->teams()->first(); + refreshSession($newTeam); + + return redirect()->route('team.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index e884abb4e..c86fa377d 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -5,11 +5,14 @@ use App\Models\Environment; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class Show extends Component { + use AuthorizesRequests; + public Project $project; public string $name; @@ -41,6 +44,7 @@ public function mount(string $project_uuid) public function submit() { try { + $this->authorize('create', Environment::class); $this->validate(); $environment = Environment::create([ 'name' => $this->name, diff --git a/tests/Feature/DestinationOwnershipTest.php b/tests/Feature/DestinationOwnershipTest.php new file mode 100644 index 000000000..1a8bf4a97 --- /dev/null +++ b/tests/Feature/DestinationOwnershipTest.php @@ -0,0 +1,60 @@ + 0]); + + // Team A owns the destination + $this->teamA = Team::factory()->create(); + $this->userA = User::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'admin']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Key A', + 'private_key' => 'test-key-a', + 'team_id' => $this->teamA->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->teamA->id, + 'private_key_id' => $keyId, + ]); + + // Use the StandaloneDocker created by the Server factory + $this->destination = $this->server->standaloneDockers()->first(); + + // Team B is a different team + $this->teamB = Team::factory()->create(); + $this->userB = User::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'admin']); +}); + +test('team member can view their own destination', function () { + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); + + Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid]) + ->assertSet('name', $this->destination->name); +}); + +test('cross-team user cannot view destination', function () { + $this->actingAs($this->userB); + session(['currentTeam' => $this->teamB]); + + Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid]) + ->assertStatus(403); +}); diff --git a/tests/Feature/NavbarDeleteTeamAuthorizationTest.php b/tests/Feature/NavbarDeleteTeamAuthorizationTest.php new file mode 100644 index 000000000..580b83299 --- /dev/null +++ b/tests/Feature/NavbarDeleteTeamAuthorizationTest.php @@ -0,0 +1,46 @@ + 0]); + + $this->owner = User::factory()->create(); + $this->personalTeam = $this->owner->teams()->first(); + $this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']); + + $this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]); + $this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']); + + $this->member = User::factory()->create(); + $this->teamToDelete->members()->attach($this->member->id, ['role' => 'member']); +}); + +test('owner can delete team via navbar', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->teamToDelete]); + + Livewire::test(NavbarDeleteTeam::class) + ->call('delete', 'password') + ->assertRedirect(route('team.index')); + + expect(Team::find($this->teamToDelete->id))->toBeNull(); +}); + +test('member cannot delete team via navbar', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->teamToDelete]); + + Livewire::test(NavbarDeleteTeam::class) + ->call('delete', 'password') + ->assertDispatched('error'); + + expect(Team::find($this->teamToDelete->id))->not->toBeNull(); +}); diff --git a/tests/Feature/ProjectEnvironmentAuthorizationTest.php b/tests/Feature/ProjectEnvironmentAuthorizationTest.php new file mode 100644 index 000000000..d1e01f0e6 --- /dev/null +++ b/tests/Feature/ProjectEnvironmentAuthorizationTest.php @@ -0,0 +1,55 @@ + 0]); + + $this->team = Team::factory()->create(); + + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); +}); + +test('admin can create environment in project', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Show::class, ['project_uuid' => $this->project->uuid]) + ->set('name', 'staging') + ->call('submit') + ->assertRedirect(); + + expect(Environment::where('name', 'staging')->exists())->toBeTrue(); +}); + +test('member cannot create environment in project', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Show::class, ['project_uuid' => $this->project->uuid]) + ->set('name', 'staging') + ->call('submit') + ->assertDispatched('error'); + + expect(Environment::where('name', 'staging')->exists())->toBeFalse(); +}); diff --git a/tests/v4/Browser/AuthorizationTest.php b/tests/v4/Browser/AuthorizationTest.php new file mode 100644 index 000000000..c867b263c --- /dev/null +++ b/tests/v4/Browser/AuthorizationTest.php @@ -0,0 +1,263 @@ + 0]); + + // Create root/owner user + $this->user = User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + // Create SSH key for the root user's team + PrivateKey::create([ + 'id' => 1, + 'uuid' => 'ssh-test', + 'team_id' => 0, + 'name' => 'Test Key', + 'description' => 'Test SSH key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + ]); + + // Create servers for testing + Server::create([ + 'id' => 0, + 'uuid' => 'localhost', + 'name' => 'localhost', + 'description' => 'Test docker container in development', + 'ip' => 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Server::create([ + 'uuid' => 'production-1', + 'name' => 'production-web', + 'description' => 'Production web server', + 'ip' => '10.0.0.1', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + // Create projects for testing + Project::create([ + 'uuid' => 'project-1', + 'name' => 'My first project', + 'description' => 'This is a test project', + 'team_id' => 0, + ]); + + Project::create([ + 'uuid' => 'project-2', + 'name' => 'Production API', + 'description' => 'Backend services', + 'team_id' => 0, + ]); + + // Create a member user attached to root team only + $this->member = User::factory()->create([ + 'name' => 'Member User', + 'email' => 'member@example.com', + 'password' => Hash::make('password'), + ]); + // Remove auto-created personal team so member only belongs to root team + $personalTeam = $this->member->teams()->first(); + $this->member->teams()->detach($personalTeam->id); + $personalTeam->delete(); + // Attach member to root team (id=0) with 'member' role + $this->member->teams()->attach(0, ['role' => 'member']); +}); + +function loginAndSkipOnboarding(): mixed +{ + return visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->click('Skip Setup'); +} + +function loginAsMember(): mixed +{ + return visit('/login') + ->fill('email', 'member@example.com') + ->fill('password', 'password') + ->click('Login'); +} + +it('redirects unauthenticated users to login', function () { + $page = visit('/dashboard'); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('shows dashboard after successful login and onboarding skip', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Dashboard') + ->assertSee('Your self-hosted infrastructure') + ->screenshot(); +}); + +it('displays all projects on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Projects') + ->assertSee('My first project') + ->assertSee('This is a test project') + ->assertSee('Production API') + ->assertSee('Backend services') + ->screenshot(); +}); + +it('displays all servers on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Servers') + ->assertSee('localhost') + ->assertSee('Test docker container in development') + ->assertSee('production-web') + ->assertSee('Production web server') + ->screenshot(); +}); + +it('allows authenticated users to access team settings', function () { + loginAndSkipOnboarding(); + + $page = visit('/team'); + + $page->assertSee('General') + ->assertSee('Manage the general settings of this team') + ->screenshot(); +}); + +it('shows danger zone to team owner', function () { + loginAndSkipOnboarding(); + + $page = visit('/team'); + + $page->assertSee('Danger Zone') + ->assertSee('Delete Team') + ->screenshot(); +}); + +it('prevents unauthenticated access to team settings', function () { + $page = visit('/team'); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('prevents unauthenticated access to server show page', function () { + $server = Server::first(); + $page = visit("/server/{$server->uuid}"); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('prevents unauthenticated access to project show page', function () { + $project = Project::first(); + $page = visit("/project/{$project->uuid}"); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('authenticated user can navigate to server details', function () { + loginAndSkipOnboarding(); + + // Navigate to server show page using UUID + $server = Server::first(); + $page = visit("/server/{$server->uuid}"); + + // Server page should load without redirect + $page->assertSee('localhost') + ->screenshot(); +}); + +it('authenticated user can navigate to project details', function () { + loginAndSkipOnboarding(); + + // Navigate to project show page using UUID + $project = Project::first(); + $page = visit("/project/{$project->uuid}"); + + // Project page should load without redirect + $page->assertSee('My first project') + ->screenshot(); +}); + +it('prevents unauthenticated access to team members page', function () { + $page = visit('/team/members'); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('authenticated user can access team members page', function () { + loginAndSkipOnboarding(); + + $page = visit('/team/members'); + + $page->assertSee('Members') + ->screenshot(); +}); + +// --- Negative authorization tests (member role) --- + +it('member does not see add project button on dashboard', function () { + $page = loginAsMember(); + + $page->assertSee('Projects') + ->assertDontSee('New Project') + ->screenshot(); +}); + +it('member does not see add server button on dashboard', function () { + $page = loginAsMember(); + + $page->assertSee('Servers') + ->assertDontSee('New Server') + ->screenshot(); +}); + +it('member does not see danger zone on team settings', function () { + loginAsMember(); + + $page = visit('/team'); + + $page->assertSee('General') + ->assertDontSee('Danger Zone') + ->assertDontSee('Delete Team') + ->screenshot(); +});