diff --git a/tests/Feature/ApiAuthorizationTest.php b/tests/Feature/ApiAuthorizationTest.php new file mode 100644 index 000000000..59bfd9659 --- /dev/null +++ b/tests/Feature/ApiAuthorizationTest.php @@ -0,0 +1,218 @@ + 0], ['is_api_enabled' => true]); + + $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']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + // Create real tokens with team_id (createToken reads from session) + session(['currentTeam' => $this->team]); + $this->adminRootToken = $this->admin->createToken('admin-root', ['root']); + $this->adminReadToken = $this->admin->createToken('admin-read', ['read']); + $this->memberRootToken = $this->member->createToken('member-root', ['root']); +}); + +// --- Unauthenticated Access --- + +test('unauthenticated request to api returns 401', function () { + $this->getJson('/api/v1/projects')->assertStatus(401); +}); + +// --- Admin with root token --- + +test('admin with root token can list projects', function () { + $this->withToken($this->adminRootToken->plainTextToken) + ->getJson('/api/v1/projects') + ->assertSuccessful(); +}); + +test('admin with root token can view project', function () { + $this->withToken($this->adminRootToken->plainTextToken) + ->getJson("/api/v1/projects/{$this->project->uuid}") + ->assertSuccessful(); +}); + +test('admin with root token can view application', function () { + $this->withToken($this->adminRootToken->plainTextToken) + ->getJson("/api/v1/applications/{$this->application->uuid}") + ->assertSuccessful(); +}); + +test('admin with root token can view server', function () { + $this->withToken($this->adminRootToken->plainTextToken) + ->getJson("/api/v1/servers/{$this->server->uuid}") + ->assertSuccessful(); +}); + +test('admin with root token can view database', function () { + $this->withToken($this->adminRootToken->plainTextToken) + ->getJson("/api/v1/databases/{$this->database->uuid}") + ->assertSuccessful(); +}); + +// --- Member with root token (policy should deny mutations) --- + +test('member with root token can view project', function () { + $this->withToken($this->memberRootToken->plainTextToken) + ->getJson("/api/v1/projects/{$this->project->uuid}") + ->assertSuccessful(); +}); + +test('member with root token cannot delete project', function () { + $this->withToken($this->memberRootToken->plainTextToken) + ->deleteJson("/api/v1/projects/{$this->project->uuid}") + ->assertStatus(403); +}); + +test('member with root token cannot update application', function () { + $this->withToken($this->memberRootToken->plainTextToken) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'name' => 'New Name', + ]) + ->assertStatus(403); +}); + +test('member with root token cannot delete application', function () { + $this->withToken($this->memberRootToken->plainTextToken) + ->deleteJson("/api/v1/applications/{$this->application->uuid}") + ->assertStatus(403); +}); + +test('member with root token cannot delete server', function () { + $this->withToken($this->memberRootToken->plainTextToken) + ->deleteJson("/api/v1/servers/{$this->server->uuid}") + ->assertStatus(403); +}); + +test('member with root token cannot update server', function () { + $this->withToken($this->memberRootToken->plainTextToken) + ->patchJson("/api/v1/servers/{$this->server->uuid}", [ + 'name' => 'New Server Name', + ]) + ->assertStatus(403); +}); + +// --- Token ability checks --- + +test('read-only token cannot create project', function () { + $this->withToken($this->adminReadToken->plainTextToken) + ->postJson('/api/v1/projects', [ + 'name' => 'New Project', + ]) + ->assertStatus(403); +}); + +test('read-only token cannot delete application', function () { + $this->withToken($this->adminReadToken->plainTextToken) + ->deleteJson("/api/v1/applications/{$this->application->uuid}") + ->assertStatus(403); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot view project via api', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + session(['currentTeam' => $otherTeam]); + $otherToken = $otherUser->createToken('other-root', ['root']); + + $this->withToken($otherToken->plainTextToken) + ->getJson("/api/v1/projects/{$this->project->uuid}") + ->assertStatus(404); +}); + +test('user from different team cannot view application via api', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + session(['currentTeam' => $otherTeam]); + $otherToken = $otherUser->createToken('other-root', ['root']); + + $this->withToken($otherToken->plainTextToken) + ->getJson("/api/v1/applications/{$this->application->uuid}") + ->assertStatus(404); +}); + +test('user from different team cannot view server via api', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + session(['currentTeam' => $otherTeam]); + $otherToken = $otherUser->createToken('other-root', ['root']); + + $this->withToken($otherToken->plainTextToken) + ->getJson("/api/v1/servers/{$this->server->uuid}") + ->assertStatus(404); +}); diff --git a/tests/Feature/ApplicationConfigAuthorizationTest.php b/tests/Feature/ApplicationConfigAuthorizationTest.php new file mode 100644 index 000000000..2c3aa123f --- /dev/null +++ b/tests/Feature/ApplicationConfigAuthorizationTest.php @@ -0,0 +1,243 @@ + 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']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); +}); + +// --- Application Policy: view --- + +test('admin can view application', function () { + expect($this->admin->can('view', $this->application))->toBeTrue(); +}); + +test('member can view application', function () { + expect($this->member->can('view', $this->application))->toBeTrue(); +}); + +// --- Application Policy: update --- + +test('admin can update application', function () { + expect($this->admin->can('update', $this->application))->toBeTrue(); +}); + +test('member cannot update application', function () { + expect($this->member->can('update', $this->application))->toBeFalse(); +}); + +// --- Application Policy: deploy --- + +test('admin can deploy application', function () { + expect($this->admin->can('deploy', $this->application))->toBeTrue(); +}); + +test('member cannot deploy application', function () { + expect($this->member->can('deploy', $this->application))->toBeFalse(); +}); + +// --- Application Policy: delete --- + +test('admin can delete application', function () { + expect($this->admin->can('delete', $this->application))->toBeTrue(); +}); + +test('member cannot delete application', function () { + expect($this->member->can('delete', $this->application))->toBeFalse(); +}); + +// --- Application Policy: manageEnvironment --- + +test('admin can manage application environment', function () { + expect($this->admin->can('manageEnvironment', $this->application))->toBeTrue(); +}); + +test('member cannot manage application environment', function () { + expect($this->member->can('manageEnvironment', $this->application))->toBeFalse(); +}); + +// --- Application Heading Livewire actions --- + +test('member cannot call deploy on application heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->call('deploy') + ->assertDispatched('error'); +}); + +test('member cannot call restart on application heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->call('restart') + ->assertDispatched('error'); +}); + +test('member cannot call stop on application heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->call('stop') + ->assertDispatched('error'); +}); + +test('member cannot call force deploy on application heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->call('force_deploy_without_cache') + ->assertDispatched('error'); +}); + +// --- Application General policy (Livewire mount requires full app data) --- + +test('member cannot update application general settings', function () { + expect($this->member->can('update', $this->application))->toBeFalse(); +}); + +// --- Application Advanced Livewire actions --- + +test('member cannot save application advanced settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationAdvanced::class, ['application' => $this->application]) + ->call('instantSave') + ->assertDispatched('error'); +}); + +test('member cannot submit application advanced settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationAdvanced::class, ['application' => $this->application]) + ->call('submit') + ->assertDispatched('error'); +}); + +// --- Application Rollback Livewire actions --- + +test('member cannot save rollback settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationRollback::class, ['application' => $this->application]) + ->call('saveSettings') + ->assertDispatched('error'); +}); + +test('member cannot rollback image', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationRollback::class, ['application' => $this->application]) + ->call('rollbackImage', 'test-image:latest') + ->assertForbidden(); +}); + +// --- Application Heading visibility --- + +test('member does not see terminal link for application', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->assertDontSee('Terminal'); +}); + +test('admin sees terminal link for application', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->assertSee('Terminal'); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot view application', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('view', $this->application))->toBeFalse(); +}); + +test('user from different team cannot update application', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('update', $this->application))->toBeFalse(); +}); diff --git a/tests/Feature/CloudProviderAuthorizationTest.php b/tests/Feature/CloudProviderAuthorizationTest.php new file mode 100644 index 000000000..46185e9bb --- /dev/null +++ b/tests/Feature/CloudProviderAuthorizationTest.php @@ -0,0 +1,162 @@ + 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']); + + DB::table('private_keys')->insert([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Team SSH Key', + 'description' => 'Key for testing', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->privateKey = PrivateKey::where('team_id', $this->team->id)->first(); +}); + +// --- Private Key Policy --- + +test('admin can create private key', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', PrivateKey::class))->toBeTrue(); +}); + +test('member cannot create private key', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', PrivateKey::class))->toBeFalse(); +}); + +test('admin can view private key', function () { + expect($this->admin->can('view', $this->privateKey))->toBeTrue(); +}); + +test('member can view own team private key', function () { + expect($this->member->can('view', $this->privateKey))->toBeTrue(); +}); + +test('admin can update private key', function () { + expect($this->admin->can('update', $this->privateKey))->toBeTrue(); +}); + +test('member cannot update private key', function () { + expect($this->member->can('update', $this->privateKey))->toBeFalse(); +}); + +test('admin can delete private key', function () { + expect($this->admin->can('delete', $this->privateKey))->toBeTrue(); +}); + +test('member cannot delete private key', function () { + expect($this->member->can('delete', $this->privateKey))->toBeFalse(); +}); + +test('user from different team cannot view private key', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('view', $this->privateKey))->toBeFalse(); +}); + +// --- Cloud Provider Token Policy --- + +test('admin can create cloud provider token', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', CloudProviderToken::class))->toBeTrue(); +}); + +test('member cannot create cloud provider token', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', CloudProviderToken::class))->toBeFalse(); +}); + +test('admin can view any cloud provider tokens', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('viewAny', CloudProviderToken::class))->toBeTrue(); +}); + +// --- Cloud Init Script Policy --- + +test('admin can create cloud init script', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', CloudInitScript::class))->toBeTrue(); +}); + +test('member cannot create cloud init script', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', CloudInitScript::class))->toBeFalse(); +}); + +test('admin can view any cloud init scripts', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('viewAny', CloudInitScript::class))->toBeTrue(); +}); + +// --- Personal Access Token (API Token) Policy --- + +test('any user can create personal access token', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', PersonalAccessToken::class))->toBeTrue(); +}); + +test('admin can use root permissions for api tokens', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('useRootPermissions', PersonalAccessToken::class))->toBeTrue(); +}); + +test('member cannot use root permissions for api tokens', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('useRootPermissions', PersonalAccessToken::class))->toBeFalse(); +}); + +test('member cannot use write permissions for api tokens', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('useWritePermissions', PersonalAccessToken::class))->toBeFalse(); +}); diff --git a/tests/Feature/DatabaseConfigAuthorizationTest.php b/tests/Feature/DatabaseConfigAuthorizationTest.php new file mode 100644 index 000000000..89c3d5e2f --- /dev/null +++ b/tests/Feature/DatabaseConfigAuthorizationTest.php @@ -0,0 +1,188 @@ + 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']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->databaseUrl = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}"; +}); + +// --- Database Policy: view --- + +test('admin can view database', function () { + expect($this->admin->can('view', $this->database))->toBeTrue(); +}); + +test('member can view database', function () { + expect($this->member->can('view', $this->database))->toBeTrue(); +}); + +// --- Database Policy: update --- + +test('admin can update database', function () { + expect($this->admin->can('update', $this->database))->toBeTrue(); +}); + +test('member cannot update database', function () { + expect($this->member->can('update', $this->database))->toBeFalse(); +}); + +// --- Database Policy: delete --- + +test('admin can delete database', function () { + expect($this->admin->can('delete', $this->database))->toBeTrue(); +}); + +test('member cannot delete database', function () { + expect($this->member->can('delete', $this->database))->toBeFalse(); +}); + +// --- Database Policy: manage --- + +test('admin can manage database', function () { + expect($this->admin->can('manage', $this->database))->toBeTrue(); +}); + +test('member cannot manage database', function () { + expect($this->member->can('manage', $this->database))->toBeFalse(); +}); + +// --- Database Policy: manageBackups --- + +test('admin can manage database backups', function () { + expect($this->admin->can('manageBackups', $this->database))->toBeTrue(); +}); + +test('member cannot manage database backups', function () { + expect($this->member->can('manageBackups', $this->database))->toBeFalse(); +}); + +// --- Database Policy: manageEnvironment --- + +test('admin can manage database environment variables', function () { + expect($this->admin->can('manageEnvironment', $this->database))->toBeTrue(); +}); + +test('member cannot manage database environment variables', function () { + expect($this->member->can('manageEnvironment', $this->database))->toBeFalse(); +}); + +// --- Database page access --- + +test('admin can access database page', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $this->get($this->databaseUrl)->assertSuccessful(); +}); + +test('member can access database page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $this->get($this->databaseUrl)->assertSuccessful(); +}); + +// --- Terminal access --- + +test('admin can access terminal for database', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('canAccessTerminal'))->toBeTrue(); +}); + +test('member cannot access terminal for database', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('canAccessTerminal'))->toBeFalse(); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot view database', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('view', $this->database))->toBeFalse(); +}); + +test('user from different team cannot update database', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('update', $this->database))->toBeFalse(); +}); + +test('user from different team cannot manage database', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('manage', $this->database))->toBeFalse(); +}); diff --git a/tests/Feature/NotificationAuthorizationTest.php b/tests/Feature/NotificationAuthorizationTest.php new file mode 100644 index 000000000..7a7ac94aa --- /dev/null +++ b/tests/Feature/NotificationAuthorizationTest.php @@ -0,0 +1,277 @@ + 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']); +}); + +// --- Discord --- + +test('member cannot send test notification on discord', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $settings = $this->team->discordNotificationSettings; + $settings->update([ + 'discord_enabled' => true, + 'discord_webhook_url' => 'https://discord.com/api/webhooks/test', + ]); + + Livewire::test(DiscordNotification::class) + ->call('sendTestNotification') + ->assertDispatched('error'); +}); + +test('member cannot update discord notification settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(DiscordNotification::class) + ->call('syncData', true) + ->assertForbidden(); +}); + +test('admin can update discord notification settings', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $settings = $this->team->discordNotificationSettings; + + expect($this->admin->can('update', $settings))->toBeTrue(); +}); + +// --- Slack --- + +test('member cannot send test notification on slack', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $settings = $this->team->slackNotificationSettings; + $settings->update([ + 'slack_enabled' => true, + 'slack_webhook_url' => 'https://hooks.slack.com/services/test', + ]); + + Livewire::test(SlackNotification::class) + ->call('sendTestNotification') + ->assertDispatched('error'); +}); + +test('member cannot update slack notification settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(SlackNotification::class) + ->call('syncData', true) + ->assertForbidden(); +}); + +test('admin can update slack notification settings', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $settings = $this->team->slackNotificationSettings; + + expect($this->admin->can('update', $settings))->toBeTrue(); +}); + +// --- Telegram --- + +test('member cannot send test notification on telegram', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $settings = $this->team->telegramNotificationSettings; + $settings->update([ + 'telegram_enabled' => true, + 'telegram_token' => 'test-token', + 'telegram_chat_id' => '123456', + ]); + + Livewire::test(TelegramNotification::class) + ->call('sendTestNotification') + ->assertDispatched('error'); +}); + +test('member cannot update telegram notification settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(TelegramNotification::class) + ->call('syncData', true) + ->assertForbidden(); +}); + +test('admin can update telegram notification settings', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $settings = $this->team->telegramNotificationSettings; + + expect($this->admin->can('update', $settings))->toBeTrue(); +}); + +// --- Email --- + +test('member cannot send test email notification', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $settings = $this->team->emailNotificationSettings; + $settings->update([ + 'smtp_enabled' => true, + 'smtp_host' => 'localhost', + 'smtp_port' => 587, + 'smtp_from_address' => 'test@test.com', + 'smtp_from_name' => 'Test', + ]); + + Livewire::test(EmailNotification::class) + ->call('sendTestEmail') + ->assertDispatched('error'); +}); + +test('member cannot update email notification settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(EmailNotification::class) + ->call('syncData', true) + ->assertForbidden(); +}); + +test('member cannot copy instance email settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(EmailNotification::class) + ->call('copyFromInstanceSettings') + ->assertForbidden(); +}); + +test('admin can update email notification settings', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $settings = $this->team->emailNotificationSettings; + + expect($this->admin->can('update', $settings))->toBeTrue(); +}); + +// --- Pushover --- + +test('member cannot send test notification on pushover', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $settings = $this->team->pushoverNotificationSettings; + $settings->update([ + 'pushover_enabled' => true, + 'pushover_app_token' => 'test-token', + 'pushover_user_key' => 'test-user-key', + ]); + + Livewire::test(PushoverNotification::class) + ->call('sendTestNotification') + ->assertDispatched('error'); +}); + +test('member cannot update pushover notification settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(PushoverNotification::class) + ->call('syncData', true) + ->assertForbidden(); +}); + +test('admin can update pushover notification settings', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $settings = $this->team->pushoverNotificationSettings; + + expect($this->admin->can('update', $settings))->toBeTrue(); +}); + +// --- Webhook --- + +test('member cannot send test notification on webhook', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $settings = $this->team->webhookNotificationSettings; + $settings->update([ + 'webhook_enabled' => true, + 'webhook_url' => 'https://example.com/webhook', + ]); + + Livewire::test(WebhookNotification::class) + ->call('sendTestNotification') + ->assertDispatched('error'); +}); + +test('member cannot update webhook notification settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(WebhookNotification::class) + ->call('syncData', true) + ->assertForbidden(); +}); + +test('admin can update webhook notification settings', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $settings = $this->team->webhookNotificationSettings; + + expect($this->admin->can('update', $settings))->toBeTrue(); +}); + +// --- Send test policy checks --- + +test('admin can send test on all notification channels', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect($this->admin->can('sendTest', $this->team->discordNotificationSettings))->toBeTrue(); + expect($this->admin->can('sendTest', $this->team->slackNotificationSettings))->toBeTrue(); + expect($this->admin->can('sendTest', $this->team->telegramNotificationSettings))->toBeTrue(); + expect($this->admin->can('sendTest', $this->team->emailNotificationSettings))->toBeTrue(); + expect($this->admin->can('sendTest', $this->team->pushoverNotificationSettings))->toBeTrue(); + expect($this->admin->can('sendTest', $this->team->webhookNotificationSettings))->toBeTrue(); +}); + +test('member cannot send test on any notification channel', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect($this->member->can('sendTest', $this->team->discordNotificationSettings))->toBeFalse(); + expect($this->member->can('sendTest', $this->team->slackNotificationSettings))->toBeFalse(); + expect($this->member->can('sendTest', $this->team->telegramNotificationSettings))->toBeFalse(); + expect($this->member->can('sendTest', $this->team->emailNotificationSettings))->toBeFalse(); + expect($this->member->can('sendTest', $this->team->pushoverNotificationSettings))->toBeFalse(); + expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse(); +}); diff --git a/tests/Feature/ResourceHeadingAuthorizationTest.php b/tests/Feature/ResourceHeadingAuthorizationTest.php new file mode 100644 index 000000000..03f11c22f --- /dev/null +++ b/tests/Feature/ResourceHeadingAuthorizationTest.php @@ -0,0 +1,248 @@ + 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']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->service = Service::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => 'version: "3"', + ]); + + $this->serviceParams = [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'service_uuid' => $this->service->uuid, + ]; + + $this->databaseUrl = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}"; +}); + +// --- Application Heading --- + +test('admin sees deploy controls for application', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->assertSee('Redeploy') + ->assertSee('Restart') + ->assertSee('Stop'); +}); + +test('member cannot call deploy on application', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->call('deploy') + ->assertDispatched('error'); +}); + +test('member cannot call restart on application', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->call('restart') + ->assertDispatched('error'); +}); + +test('member cannot call stop on application', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->call('stop') + ->assertDispatched('error'); +}); + +test('member does not see terminal link for application', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->assertDontSee('Terminal'); +}); + +test('admin sees terminal link for application', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationHeading::class, ['application' => $this->application]) + ->assertSee('Terminal'); +}); + +// --- Database Heading (via page route for rendering, policy checks for actions) --- + +test('admin can access database page', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $this->get($this->databaseUrl)->assertSuccessful(); +}); + +test('member can access database page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $this->get($this->databaseUrl)->assertSuccessful(); +}); + +test('admin can manage database', function () { + expect($this->admin->can('manage', $this->database))->toBeTrue(); +}); + +test('member cannot manage database', function () { + expect($this->member->can('manage', $this->database))->toBeFalse(); +}); + +test('admin can access terminal for database', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('canAccessTerminal'))->toBeTrue(); +}); + +test('member cannot access terminal for database', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('canAccessTerminal'))->toBeFalse(); +}); + +// --- Service Heading --- + +test('admin sees service deploy button', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->assertSee('Deploy'); +}); + +test('member cannot call stop on service', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->call('stop') + ->assertDispatched('error'); +}); + +test('member does not see terminal link for service', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->assertDontSee('Terminal'); +}); + +test('admin sees terminal link for service', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->assertSee('Terminal'); +}); diff --git a/tests/Feature/SecurityPageAuthorizationTest.php b/tests/Feature/SecurityPageAuthorizationTest.php new file mode 100644 index 000000000..94975f3c2 --- /dev/null +++ b/tests/Feature/SecurityPageAuthorizationTest.php @@ -0,0 +1,112 @@ + 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']); + + // Create a private key for the team (bypass model validation via DB insert) + DB::table('private_keys')->insert([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Team SSH Key', + 'description' => 'Key for testing', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); +}); + +// --- Private Key Index --- + +test('admin sees add and cleanup buttons on private key page', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(PrivateKeyIndex::class) + ->assertSee('+ Add') + ->assertSee('Delete unused SSH Keys'); +}); + +test('member does not see add or cleanup buttons on private key page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(PrivateKeyIndex::class) + ->assertDontSee('+ Add') + ->assertDontSee('Delete unused SSH Keys'); +}); + +test('member can view private key names on index page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + // Member can see key names (view is allowed for same-team members) + // but cannot see create/delete buttons (tested separately above) + Livewire::test(PrivateKeyIndex::class) + ->assertSee('Team SSH Key'); +}); + +test('member cannot call cleanup unused keys', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(PrivateKeyIndex::class) + ->call('cleanupUnusedKeys') + ->assertDispatched('error'); +}); + +// --- Team Member Index (Invitations) --- + +test('admin sees invite section on team members page', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(TeamMemberIndex::class) + ->assertSee('Invite New Member'); +}); + +test('member does not see invite section on team members page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(TeamMemberIndex::class) + ->assertDontSee('Invite New Member'); +}); + +// --- Notification Settings (Discord as representative) --- + +test('member cannot send test notification on discord', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $settings = DiscordNotificationSettings::where('team_id', $this->team->id)->first(); + $settings->update([ + 'discord_enabled' => true, + 'discord_webhook_url' => 'https://discord.com/api/webhooks/test', + ]); + + Livewire::test(DiscordNotification::class) + ->call('sendTestNotification') + ->assertDispatched('error'); +}); diff --git a/tests/Feature/ServerAuthorizationTest.php b/tests/Feature/ServerAuthorizationTest.php new file mode 100644 index 000000000..80b7aed40 --- /dev/null +++ b/tests/Feature/ServerAuthorizationTest.php @@ -0,0 +1,222 @@ + 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']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); +}); + +// --- Server Policy: update --- + +test('admin can update server', function () { + expect($this->admin->can('update', $this->server))->toBeTrue(); +}); + +test('member cannot update server', function () { + expect($this->member->can('update', $this->server))->toBeFalse(); +}); + +// --- Server Policy: delete --- + +test('admin can delete server', function () { + expect($this->admin->can('delete', $this->server))->toBeTrue(); +}); + +test('member cannot delete server', function () { + expect($this->member->can('delete', $this->server))->toBeFalse(); +}); + +// --- Server Policy: view --- + +test('admin can view server', function () { + expect($this->admin->can('view', $this->server))->toBeTrue(); +}); + +test('member can view server', function () { + expect($this->member->can('view', $this->server))->toBeTrue(); +}); + +// --- Server Policy: manageProxy --- + +test('admin can manage proxy', function () { + expect($this->admin->can('manageProxy', $this->server))->toBeTrue(); +}); + +test('member cannot manage proxy', function () { + expect($this->member->can('manageProxy', $this->server))->toBeFalse(); +}); + +// --- Server Policy: manageSentinel --- + +test('admin can manage sentinel', function () { + expect($this->admin->can('manageSentinel', $this->server))->toBeTrue(); +}); + +test('member cannot manage sentinel', function () { + expect($this->member->can('manageSentinel', $this->server))->toBeFalse(); +}); + +// --- Server Policy: manageCaCertificate --- + +test('admin can manage CA certificate', function () { + expect($this->admin->can('manageCaCertificate', $this->server))->toBeTrue(); +}); + +test('member cannot manage CA certificate', function () { + expect($this->member->can('manageCaCertificate', $this->server))->toBeFalse(); +}); + +// --- Server Policy: viewSecurity --- + +test('admin can view security', function () { + expect($this->admin->can('viewSecurity', $this->server))->toBeTrue(); +}); + +test('member cannot view security', function () { + expect($this->member->can('viewSecurity', $this->server))->toBeFalse(); +}); + +// --- Server Policy: create --- + +test('admin can create server', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', Server::class))->toBeTrue(); +}); + +test('member cannot create server', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('create', Server::class))->toBeFalse(); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot view server', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('view', $this->server))->toBeFalse(); +}); + +test('user from different team cannot update server', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('update', $this->server))->toBeFalse(); +}); + +// --- Navbar Livewire component actions --- + +test('member cannot restart proxy via navbar', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServerNavbar::class, ['server' => $this->server]) + ->call('restart') + ->assertDispatched('error'); +}); + +test('member cannot check proxy via navbar', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServerNavbar::class, ['server' => $this->server]) + ->call('checkProxy') + ->assertDispatched('error'); +}); + +test('member cannot start proxy via navbar', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServerNavbar::class, ['server' => $this->server]) + ->call('startProxy') + ->assertDispatched('error'); +}); + +test('member cannot stop proxy via navbar', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServerNavbar::class, ['server' => $this->server]) + ->call('stop') + ->assertDispatched('error'); +}); + +// --- Terminal access gate --- + +test('admin can access terminal', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('canAccessTerminal'))->toBeTrue(); +}); + +test('member cannot access terminal', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('canAccessTerminal'))->toBeFalse(); +}); + +// --- Server page access --- + +test('admin can access server page', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $this->get("/server/{$this->server->uuid}")->assertSuccessful(); +}); + +test('member can access server page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $this->get("/server/{$this->server->uuid}")->assertSuccessful(); +}); + +test('unauthenticated user cannot access server page', function () { + $this->get("/server/{$this->server->uuid}")->assertRedirect('/login'); +}); diff --git a/tests/Feature/ServiceConfigAuthorizationTest.php b/tests/Feature/ServiceConfigAuthorizationTest.php new file mode 100644 index 000000000..ab5c12376 --- /dev/null +++ b/tests/Feature/ServiceConfigAuthorizationTest.php @@ -0,0 +1,242 @@ + 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']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->service = Service::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => 'version: "3"', + ]); + + $this->serviceParams = [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'service_uuid' => $this->service->uuid, + ]; +}); + +// --- Service Policy: view --- + +test('admin can view service', function () { + expect($this->admin->can('view', $this->service))->toBeTrue(); +}); + +test('member can view service', function () { + expect($this->member->can('view', $this->service))->toBeTrue(); +}); + +// --- Service Policy: update --- + +test('admin can update service', function () { + expect($this->admin->can('update', $this->service))->toBeTrue(); +}); + +test('member cannot update service', function () { + expect($this->member->can('update', $this->service))->toBeFalse(); +}); + +// --- Service Policy: delete --- + +test('admin can delete service', function () { + expect($this->admin->can('delete', $this->service))->toBeTrue(); +}); + +test('member cannot delete service', function () { + expect($this->member->can('delete', $this->service))->toBeFalse(); +}); + +// --- Service Policy: deploy --- + +test('admin can deploy service', function () { + expect($this->admin->can('deploy', $this->service))->toBeTrue(); +}); + +test('member cannot deploy service', function () { + expect($this->member->can('deploy', $this->service))->toBeFalse(); +}); + +// --- Service Policy: stop --- + +test('admin can stop service', function () { + expect($this->admin->can('stop', $this->service))->toBeTrue(); +}); + +test('member cannot stop service', function () { + expect($this->member->can('stop', $this->service))->toBeFalse(); +}); + +// --- Service Policy: manageEnvironment --- + +test('admin can manage service environment variables', function () { + expect($this->admin->can('manageEnvironment', $this->service))->toBeTrue(); +}); + +test('member cannot manage service environment variables', function () { + expect($this->member->can('manageEnvironment', $this->service))->toBeFalse(); +}); + +// --- Service Heading Livewire actions --- + +test('member cannot call start on service heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->call('start') + ->assertDispatched('error'); +}); + +test('member cannot call stop on service heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->call('stop') + ->assertDispatched('error'); +}); + +test('member cannot call restart on service heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->call('restart') + ->assertDispatched('error'); +}); + +test('member cannot call force deploy on service heading', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->call('forceDeploy') + ->assertDispatched('error'); +}); + +// --- Service Heading visibility --- + +test('member does not see terminal link for service', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->assertDontSee('Terminal'); +}); + +test('admin sees terminal link for service', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->assertSee('Terminal'); +}); + +test('admin sees deploy button for service', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ServiceHeading::class, [ + 'service' => $this->service, + 'parameters' => $this->serviceParams, + 'query' => [], + ]) + ->assertSee('Deploy'); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot view service', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('view', $this->service))->toBeFalse(); +}); + +test('user from different team cannot update service', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('update', $this->service))->toBeFalse(); +}); diff --git a/tests/Feature/SharedResourceAuthorizationTest.php b/tests/Feature/SharedResourceAuthorizationTest.php new file mode 100644 index 000000000..23f134458 --- /dev/null +++ b/tests/Feature/SharedResourceAuthorizationTest.php @@ -0,0 +1,256 @@ + 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']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->service = Service::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => 'version: "3"', + ]); +}); + +// --- Danger Zone: Application --- + +test('admin can delete application', function () { + expect($this->admin->can('delete', $this->application))->toBeTrue(); +}); + +test('member cannot delete application via danger zone', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'password') + ->assertDispatched('error'); +}); + +// --- Danger Zone: Database --- + +test('admin can delete database', function () { + expect($this->admin->can('delete', $this->database))->toBeTrue(); +}); + +test('member cannot delete database via danger zone', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Danger::class, ['resource' => $this->database]) + ->call('delete', 'password') + ->assertDispatched('error'); +}); + +// --- Danger Zone: Service --- + +test('admin can delete service', function () { + expect($this->admin->can('delete', $this->service))->toBeTrue(); +}); + +test('member cannot delete service via danger zone', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Danger::class, ['resource' => $this->service]) + ->call('delete', 'password') + ->assertDispatched('error'); +}); + +// --- Tags: Application --- + +test('member cannot add tag to application', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Tags::class, ['resource' => $this->application]) + ->set('newTags', 'test-tag') + ->call('submit') + ->assertDispatched('error'); +}); + +test('admin can add tag to application', function () { + expect($this->admin->can('update', $this->application))->toBeTrue(); +}); + +// --- Webhooks: Application --- + +test('member cannot update application webhooks', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Webhooks::class, ['resource' => $this->application]) + ->call('submit') + ->assertDispatched('error'); +}); + +test('admin can update application webhooks', function () { + expect($this->admin->can('update', $this->application))->toBeTrue(); +}); + +// --- Resource Limits (policy checks, mount requires full resource data) --- + +test('member cannot update application resource limits', function () { + expect($this->member->can('update', $this->application))->toBeFalse(); +}); + +test('admin can update application resource limits', function () { + expect($this->admin->can('update', $this->application))->toBeTrue(); +}); + +test('member cannot update database resource limits', function () { + expect($this->member->can('update', $this->database))->toBeFalse(); +}); + +test('member cannot update service resource limits', function () { + expect($this->member->can('update', $this->service))->toBeFalse(); +}); + +// --- Environment Variables (Policy checks) --- + +test('admin can manage application environment variables', function () { + expect($this->admin->can('manageEnvironment', $this->application))->toBeTrue(); +}); + +test('member cannot manage application environment variables', function () { + expect($this->member->can('manageEnvironment', $this->application))->toBeFalse(); +}); + +test('admin can manage database environment variables', function () { + expect($this->admin->can('manageEnvironment', $this->database))->toBeTrue(); +}); + +test('member cannot manage database environment variables', function () { + expect($this->member->can('manageEnvironment', $this->database))->toBeFalse(); +}); + +test('admin can manage service environment variables', function () { + expect($this->admin->can('manageEnvironment', $this->service))->toBeTrue(); +}); + +test('member cannot manage service environment variables', function () { + expect($this->member->can('manageEnvironment', $this->service))->toBeFalse(); +}); + +// --- Project Policy --- + +test('admin can update project', function () { + expect($this->admin->can('update', $this->project))->toBeTrue(); +}); + +test('member cannot update project', function () { + expect($this->member->can('update', $this->project))->toBeFalse(); +}); + +test('admin can delete project', function () { + expect($this->admin->can('delete', $this->project))->toBeTrue(); +}); + +test('member cannot delete project', function () { + expect($this->member->can('delete', $this->project))->toBeFalse(); +}); + +// --- Environment Policy --- + +test('admin can delete environment', function () { + expect($this->admin->can('delete', $this->environment))->toBeTrue(); +}); + +test('member cannot delete environment', function () { + expect($this->member->can('delete', $this->environment))->toBeFalse(); +}); + +// --- Team Policy --- + +test('admin can update team', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('update', $this->team))->toBeTrue(); +}); + +test('member cannot update team', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('update', $this->team))->toBeFalse(); +}); diff --git a/tests/Feature/TeamAuthorizationTest.php b/tests/Feature/TeamAuthorizationTest.php new file mode 100644 index 000000000..af30c8dee --- /dev/null +++ b/tests/Feature/TeamAuthorizationTest.php @@ -0,0 +1,207 @@ + 0]); + + $this->team = Team::factory()->create(['personal_team' => false]); + + $this->owner = User::factory()->create(); + $this->owner->teams()->attach($this->team, ['role' => 'owner']); + + $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']); +}); + +// --- Team Policy: update --- + +test('owner can update team', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('update', $this->team))->toBeTrue(); +}); + +test('admin can update team', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('update', $this->team))->toBeTrue(); +}); + +test('member cannot update team', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('update', $this->team))->toBeFalse(); +}); + +// --- Team Policy: delete --- + +test('owner can delete team', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('delete', $this->team))->toBeTrue(); +}); + +test('admin can delete team', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('delete', $this->team))->toBeTrue(); +}); + +test('member cannot delete team', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('delete', $this->team))->toBeFalse(); +}); + +// --- Team Policy: manageMembers --- + +test('owner can manage team members', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageMembers', $this->team))->toBeTrue(); +}); + +test('admin can manage team members', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageMembers', $this->team))->toBeTrue(); +}); + +test('member cannot manage team members', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageMembers', $this->team))->toBeFalse(); +}); + +// --- Team Policy: manageInvitations --- + +test('owner can manage invitations', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue(); +}); + +test('admin can manage invitations', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue(); +}); + +test('member cannot manage invitations', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageInvitations', $this->team))->toBeFalse(); +}); + +// --- Team Index Livewire: update --- + +test('member cannot submit team settings via policy', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('update', $this->team))->toBeFalse(); +}); + +// --- Team Index Livewire: delete --- + +test('member cannot delete team via index', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(TeamIndex::class) + ->call('delete', 'password') + ->assertDispatched('error'); + + expect(Team::find($this->team->id))->not->toBeNull(); +}); + +test('admin can delete team via policy', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('delete', $this->team))->toBeTrue(); +}); + +// --- Team Member Livewire: role changes --- + +test('member cannot change roles via team member component', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(TeamMember::class, ['member' => $this->admin]) + ->call('makeAdmin') + ->assertDispatched('error'); +}); + +test('member cannot remove team members', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(TeamMember::class, ['member' => $this->admin]) + ->call('remove') + ->assertDispatched('error'); +}); + +// --- Invitations & Invite Link (policy checks — views wrapped in @can render empty for members) --- + +test('member cannot manage invitations via policy', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageInvitations', $this->team))->toBeFalse(); +}); + +test('owner can manage invitations via policy', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue(); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot update team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'owner']); + + $this->actingAs($otherUser); + session(['currentTeam' => $otherTeam]); + + expect(auth()->user()->can('update', $this->team))->toBeFalse(); +}); + +test('user from different team cannot manage members', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'owner']); + + $this->actingAs($otherUser); + session(['currentTeam' => $otherTeam]); + + expect(auth()->user()->can('manageMembers', $this->team))->toBeFalse(); +}); diff --git a/tests/v4/Browser/AuthorizationTest.php b/tests/v4/Browser/AuthorizationTest.php index 50751e431..cb26b73a9 100644 --- a/tests/v4/Browser/AuthorizationTest.php +++ b/tests/v4/Browser/AuthorizationTest.php @@ -343,9 +343,105 @@ function loginAsMember(): mixed ->screenshot(); }); -// Note: Application, database, and service deploy/restart/stop controls -// are tested via unit policy tests (tests/Unit/Policies/). -// Browser tests for these resource pages require complex model chain setup -// (Application -> Environment -> Project -> Team, with StandaloneDocker destination) -// that causes Livewire mount() to redirect due to model relationship loading issues -// in the browser test context. +// --- Dashboard: resource links authorization --- + +it('member does not see add resource link on dashboard project cards', function () { + $page = loginAsMember(); + + $page->assertSee('Projects') + ->assertSee('My first project') + ->assertDontSee('+ Add Resource') + ->screenshot(); +}); + +it('owner sees add resource link on dashboard project cards', function () { + loginAndSkipBoarding(); + + $page = visit('/dashboard'); + + $page->assertSee('Projects') + ->assertSee('My first project') + ->assertSee('+ Add Resource') + ->screenshot(); +}); + +it('member does not see project settings link on dashboard', function () { + $page = loginAsMember(); + + // The "Settings" link is inside the project card on the dashboard + // Member should not see it due to @can('update', $project) + $page->assertSee('My first project') + ->assertDontSee('Settings') + ->screenshot(); +}); + +// --- Team members page authorization --- + +it('member does not see invite form on team members page', function () { + loginAsMember(); + + $page = visit('/team/members'); + + $page->assertSee('Members') + ->assertDontSee('Invite New Member') + ->screenshot(); +}); + +it('owner sees invite form on team members page', function () { + loginAndSkipBoarding(); + + $page = visit('/team/members'); + + $page->assertSee('Members') + ->assertSee('Invite New Member') + ->screenshot(); +}); + +it('member does not see role change buttons on team members page', function () { + loginAsMember(); + + $page = visit('/team/members'); + + $page->assertSee('Members') + ->assertDontSee('To Admin') + ->assertDontSee('To Owner') + ->assertDontSee('Remove') + ->screenshot(); +}); + +// --- Server show page authorization --- + +it('member does not see save button on server show page', function () { + loginAsMember(); + + $server = Server::first(); + $page = visit("/server/{$server->uuid}"); + + $page->assertSee('General') + ->assertDontSee('Revalidate server') + ->screenshot(); +}); + +it('owner sees save button on server show page', function () { + loginAndSkipBoarding(); + + $server = Server::first(); + $page = visit("/server/{$server->uuid}"); + + $page->assertSee('General') + ->assertSee('Save') + ->screenshot(); +}); + +// --- Project show page authorization --- + +it('member does not see delete project button on project page', function () { + loginAsMember(); + + $project = Project::where('uuid', 'project-1')->first(); + $page = visit("/project/{$project->uuid}"); + + $page->assertSee('Environments') + ->assertDontSee('Delete Project') + ->screenshot(); +});