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
This commit is contained in:
Andras Bacsai
2026-02-25 16:38:04 +01:00
parent 41e1248b6f
commit 94dfd6a54e
8 changed files with 498 additions and 27 deletions
@@ -0,0 +1,60 @@
<?php
use App\Livewire\Destination\Show;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 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);
});
@@ -0,0 +1,46 @@
<?php
use App\Livewire\NavbarDeleteTeam;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 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();
});
@@ -0,0 +1,55 @@
<?php
use App\Livewire\Project\Show;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 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();
});
+263
View File
@@ -0,0 +1,263 @@
<?php
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 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();
});