From 2046fa453ac36642562fbcb27648e8d414ce2252 Mon Sep 17 00:00:00 2001 From: pelican-vehikl Date: Mon, 28 Apr 2025 10:20:33 -0400 Subject: [PATCH] Pest Test Improvements (#1137) Co-authored-by: Lance Pioch Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com> --- .gitignore | 3 +- app/Enums/RolePermissionModels.php | 20 ++ .../Api/Client/Servers/SettingsController.php | 14 +- app/Models/Permission.php | 3 +- app/Models/Role.php | 3 + composer.json | 2 + composer.lock | 135 ++++++++++++- database/Factories/PermissionFactory.php | 18 ++ database/Factories/RoleFactory.php | 22 +++ tests/Feature/SettingsControllerTest.php | 179 ++++++++++++++++++ tests/Filament/Admin/ListEggsTest.php | 49 +++++ tests/Filament/Admin/ListNodesTest.php | 67 +++++++ tests/Pest.php | 132 +++++++++++++ 13 files changed, 633 insertions(+), 14 deletions(-) create mode 100644 database/Factories/PermissionFactory.php create mode 100644 database/Factories/RoleFactory.php create mode 100644 tests/Feature/SettingsControllerTest.php create mode 100644 tests/Filament/Admin/ListEggsTest.php create mode 100644 tests/Filament/Admin/ListNodesTest.php diff --git a/.gitignore b/.gitignore index 91984a1ab..e0865a5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,7 @@ yarn-error.log /.vscode public/assets/manifest.json -/database/*.sqlite -/database/*.sqlite-journal +/database/*.sqlite* filament-monaco-editor/ _ide_helper* /.phpstorm.meta.php diff --git a/app/Enums/RolePermissionModels.php b/app/Enums/RolePermissionModels.php index 069910957..013d5b857 100644 --- a/app/Enums/RolePermissionModels.php +++ b/app/Enums/RolePermissionModels.php @@ -14,4 +14,24 @@ enum RolePermissionModels: string case Server = 'server'; case User = 'user'; case Webhook = 'webhook'; + + public function viewAny(): string + { + return RolePermissionPrefixes::ViewAny->value . ' ' . $this->value; + } + + public function view(): string + { + return RolePermissionPrefixes::View->value . ' ' . $this->value; + } + + public function create(): string + { + return RolePermissionPrefixes::Create->value . ' ' . $this->value; + } + + public function update(): string + { + return RolePermissionPrefixes::Update->value . ' ' . $this->value; + } } diff --git a/app/Http/Controllers/Api/Client/Servers/SettingsController.php b/app/Http/Controllers/Api/Client/Servers/SettingsController.php index ddb512b9f..169f825b5 100644 --- a/app/Http/Controllers/Api/Client/Servers/SettingsController.php +++ b/app/Http/Controllers/Api/Client/Servers/SettingsController.php @@ -36,26 +36,22 @@ class SettingsController extends ClientApiController $name = $request->input('name'); $description = $request->has('description') ? (string) $request->input('description') : $server->description; - $server->name = $name; - - if (config('panel.editable_server_descriptions')) { - $server->description = $description; - } - - $server->save(); - if ($server->name !== $name) { Activity::event('server:settings.rename') ->property(['old' => $server->name, 'new' => $name]) ->log(); + $server->name = $name; } - if ($server->description !== $description) { + if ($server->description !== $description && config('panel.editable_server_descriptions')) { Activity::event('server:settings.description') ->property(['old' => $server->description, 'new' => $description]) ->log(); + $server->description = $description; } + $server->save(); + return new JsonResponse([], Response::HTTP_NO_CONTENT); } diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 02b3870b6..62d36e2c4 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -4,12 +4,13 @@ namespace App\Models; use App\Contracts\Validatable; use App\Traits\HasValidation; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; class Permission extends Model implements Validatable { - use HasValidation; + use HasFactory, HasValidation; /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Role.php b/app/Models/Role.php index be4a93b40..5f48c62a0 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\RolePermissionModels; use App\Enums\RolePermissionPrefixes; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Spatie\Permission\Models\Role as BaseRole; /** @@ -17,6 +18,8 @@ use Spatie\Permission\Models\Role as BaseRole; */ class Role extends BaseRole { + use HasFactory; + public const RESOURCE_NAME = 'role'; public const ROOT_ADMIN = 'Root Admin'; diff --git a/composer.json b/composer.json index d43766a54..b89dcccbc 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,8 @@ "mockery/mockery": "^1.6.11", "nunomaduro/collision": "^8.6", "pestphp/pest": "^3.7", + "pestphp/pest-plugin-faker": "^3.0", + "pestphp/pest-plugin-livewire": "^3.0", "spatie/laravel-ignition": "^2.9" }, "autoload": { diff --git a/composer.lock b/composer.lock index 03bc7f6f3..dcd3cba4c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e78193e058fd9f763da97bbc11934c2d", + "content-hash": "556cda6914cb34938f738f77ab4b6949", "packages": [ { "name": "abdelhamiderrahmouni/filament-monaco-editor", @@ -13616,6 +13616,137 @@ ], "time": "2025-04-16T22:59:48+00:00" }, + { + "name": "pestphp/pest-plugin-faker", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-faker.git", + "reference": "48343e2806cfc12a042dead90ffff4a043167e3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-faker/zipball/48343e2806cfc12a042dead90ffff4a043167e3e", + "reference": "48343e2806cfc12a042dead90ffff4a043167e3e", + "shasum": "" + }, + "require": { + "fakerphp/faker": "^1.23.1", + "pestphp/pest": "^3.0.0", + "php": "^8.2" + }, + "require-dev": { + "pestphp/pest-dev-tools": "^3.0.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Faker.php" + ], + "psr-4": { + "Pest\\Faker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Faker Plugin", + "keywords": [ + "faker", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-faker/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2024-09-08T23:56:08+00:00" + }, + { + "name": "pestphp/pest-plugin-livewire", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-livewire.git", + "reference": "e2f2edb0a7d414d6837d87908a0e148256d3bf89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-livewire/zipball/e2f2edb0a7d414d6837d87908a0e148256d3bf89", + "reference": "e2f2edb0a7d414d6837d87908a0e148256d3bf89", + "shasum": "" + }, + "require": { + "livewire/livewire": "^3.5.6", + "pestphp/pest": "^3.0.0", + "php": "^8.1" + }, + "require-dev": { + "orchestra/testbench": "^9.4.0", + "pestphp/pest-dev-tools": "^3.0.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Livewire Plugin", + "keywords": [ + "framework", + "livewire", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-livewire/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2024-09-09T00:05:59+00:00" + }, { "name": "pestphp/pest-plugin-mutate", "version": "v3.0.5", @@ -15775,5 +15906,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/database/Factories/PermissionFactory.php b/database/Factories/PermissionFactory.php new file mode 100644 index 000000000..7b73fac68 --- /dev/null +++ b/database/Factories/PermissionFactory.php @@ -0,0 +1,18 @@ + $this->faker->name(), + 'guard_name' => $this->faker->name(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/tests/Feature/SettingsControllerTest.php b/tests/Feature/SettingsControllerTest.php new file mode 100644 index 000000000..c1850489e --- /dev/null +++ b/tests/Feature/SettingsControllerTest.php @@ -0,0 +1,179 @@ +group('API'); + +covers(SettingsController::class); + +it('server name cannot be changed', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_WEBSOCKET_CONNECT]); + $originalName = $server->name; + + $this->actingAs($user) + ->post("/api/client/servers/$server->uuid/settings/rename", [ + 'name' => 'Test Server Name', + ]) + ->assertStatus(Response::HTTP_FORBIDDEN); + + $server = $server->refresh(); + expect()->toLogActivities(0) + ->and($server->name)->toBe($originalName); +}); + +it('server description can be changed', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_SETTINGS_DESCRIPTION]); + $originalDescription = $server->description; + + $newDescription = 'Test Server Description'; + $this->actingAs($user) + ->post("/api/client/servers/$server->uuid/settings/description", [ + 'description' => $newDescription, + ]) + ->assertStatus(Response::HTTP_NO_CONTENT); + + $server = $server->refresh(); + $logged = \App\Models\ActivityLog::first(); + expect()->toLogActivities(1) + ->and($logged->properties['old'])->toBe($originalDescription) + ->and($logged->properties['new'])->toBe($newDescription) + ->and($server->description)->not()->toBe($originalDescription); +}); + +it('server description cannot be changed', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_SETTINGS_DESCRIPTION]); + Config::set('panel.editable_server_descriptions', false); + $originalDescription = $server->description; + + $this->actingAs($user) + ->post("/api/client/servers/$server->uuid/settings/description", [ + 'description' => 'Test Description', + ]) + ->assertStatus(Response::HTTP_NO_CONTENT); + + $server = $server->refresh(); + expect()->toLogActivities(0) + ->and($server->description)->toBe($originalDescription); +}); + +it('server name can be changed', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_WEBSOCKET_CONNECT, Permission::ACTION_SETTINGS_RENAME]); + $originalName = $server->name; + + $this->actingAs($user) + ->post("/api/client/servers/$server->uuid/settings/rename", [ + 'name' => 'Test Server Name', + ]) + ->assertStatus(Response::HTTP_NO_CONTENT); + + $server = $server->refresh(); + expect()->toLogActivities(1) + ->and($server->name)->not()->toBe($originalName); +}); + +test('unauthorized user cannot change docker image in use by server', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_WEBSOCKET_CONNECT]); + $originalImage = $server->image; + + $this->actingAs($user) + ->put("/api/client/servers/$server->uuid/settings/docker-image", [ + 'docker_image' => 'ghcr.io/pelican-dev/yolks:java_21', + ]) + ->assertStatus(Response::HTTP_FORBIDDEN); + + $server = $server->refresh(); + expect()->toLogActivities(0) + ->and($server->image)->toBe($originalImage); +}); + +test('cannot change docker image to image not allowed by egg', function () { + + [$user, $server] = generateTestAccount([Permission::ACTION_STARTUP_DOCKER_IMAGE]); + $server->image = 'ghcr.io/parkervcp/yolks:java_17'; + $server->save(); + + $newImage = 'ghcr.io/parkervcp/fake:image'; + + $server = $server->refresh(); + + $this->actingAs($user) + ->putJson("/api/client/servers/$server->uuid/settings/docker-image", [ + 'docker_image' => $newImage, + ]) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + + $server->refresh(); + expect()->toLogActivities(0) + ->and($server->image)->not()->toBe($newImage); +}); + +test('can change docker image in use by server', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_STARTUP_DOCKER_IMAGE]); + $oldImage = 'ghcr.io/parkervcp/yolks:java_17'; + $server->image = $oldImage; + $server->save(); + + $newImage = 'ghcr.io/parkervcp/yolks:java_21'; + + $this->actingAs($user) + ->putJson("/api/client/servers/$server->uuid/settings/docker-image", [ + 'docker_image' => $newImage, + ]) + ->assertStatus(Response::HTTP_NO_CONTENT); + + $server = $server->refresh(); + + $logItem = \App\Models\ActivityLog::first(); + expect()->toLogActivities(1) + ->and($logItem->properties['old'])->toBe($oldImage) + ->and($logItem->properties['new'])->toBe($newImage) + ->and($server->image)->toBe($newImage); +}); + +test('unable to change the docker image set by administrator', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_STARTUP_DOCKER_IMAGE]); + $oldImage = 'ghcr.io/parkervcp/yolks:java_custom'; + $server->image = $oldImage; + $server->save(); + + $newImage = 'ghcr.io/parkervcp/yolks:java_8'; + + $this->actingAs($user) + ->putJson("/api/client/servers/$server->uuid/settings/docker-image", [ + 'docker_image' => $newImage, + ]) + ->assertStatus(Response::HTTP_BAD_REQUEST); + + $server = $server->refresh(); + + expect()->toLogActivities(0) + ->and($server->image)->toBe($oldImage); +}); + +test('can be reinstalled', function () { + [$user, $server] = generateTestAccount([Permission::ACTION_SETTINGS_REINSTALL]); + expect($server->isInstalled())->toBeTrue(); + + $service = \Mockery::mock(DaemonServerRepository::class); + $this->app->instance(DaemonServerRepository::class, $service); + + $service->expects('setServer') + ->with(\Mockery::on(function ($value) use ($server) { + return $value->uuid === $server->uuid; + })) + ->andReturnSelf() + ->getMock() + ->expects('reinstall') + ->andReturnUndefined(); + + $this->actingAs($user)->postJson("/api/client/servers/$server->uuid/settings/reinstall") + ->assertStatus(Response::HTTP_ACCEPTED); + + $server = $server->refresh(); + expect()->toLogActivities(1) + ->and($server->status)->toBe(ServerState::Installing); +}); diff --git a/tests/Filament/Admin/ListEggsTest.php b/tests/Filament/Admin/ListEggsTest.php new file mode 100644 index 000000000..0e34b22fa --- /dev/null +++ b/tests/Filament/Admin/ListEggsTest.php @@ -0,0 +1,49 @@ +syncRoles(Role::getRootAdmin()); + + $this->actingAs($admin); + livewire(ListEggs::class) + ->assertSuccessful() + ->assertCountTableRecords($eggs->count()) + ->assertCanSeeTableRecords($eggs); +}); + +it('non root admin cannot see any eggs', function () { + $role = Role::factory()->create(['name' => 'Node Viewer', 'guard_name' => 'web']); + // Node Permission is on purpose, we check the wrong permissions. + $permission = Permission::factory()->create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']); + $role->permissions()->attach($permission); + [$user] = generateTestAccount([]); + + $this->actingAs($user); + livewire(ListEggs::class) + ->assertForbidden(); +}); + +it('non root admin with permissions can see eggs', function () { + $role = Role::factory()->create(['name' => 'Egg Viewer', 'guard_name' => 'web']); + $permission = Permission::factory()->create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']); + $role->permissions()->attach($permission); + + $eggs = Egg::all(); + [$user] = generateTestAccount([]); + $user = $user->syncRoles($role); + + $this->actingAs($user); + livewire(ListEggs::class) + ->assertSuccessful() + ->assertCountTableRecords($eggs->count()) + ->assertCanSeeTableRecords($eggs); +}); diff --git a/tests/Filament/Admin/ListNodesTest.php b/tests/Filament/Admin/ListNodesTest.php new file mode 100644 index 000000000..9f7115556 --- /dev/null +++ b/tests/Filament/Admin/ListNodesTest.php @@ -0,0 +1,67 @@ +syncRoles(Role::getRootAdmin()); + $nodes = Node::all(); + + $this->actingAs($admin); + livewire(ListNodes::class) + ->assertSuccessful() + ->assertCountTableRecords($nodes->count()) + ->assertCanSeeTableRecords($nodes); +}); + +it('non root admin cannot see any nodes', function () { + $role = Role::factory()->create(['name' => 'Egg Viewer', 'guard_name' => 'web']); + // Egg Permission is on purpose, we check the wrong permissions. + $permission = Permission::factory()->create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']); + $role->permissions()->attach($permission); + [$user] = generateTestAccount(); + + $this->actingAs($user); + livewire(ListNodes::class) + ->assertForbidden(); +}); + +it('non root admin with permissions can see nodes', function () { + $role = Role::factory()->create(['name' => 'Node Viewer', 'guard_name' => 'web']); + $permission = Permission::factory()->create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']); + $role->permissions()->attach($permission); + + [$user] = generateTestAccount(); + $nodes = Node::all(); + $user = $user->syncRoles($role); + + $this->actingAs($user); + livewire(ListNodes::class) + ->assertSuccessful() + ->assertCountTableRecords($nodes->count()) + ->assertCanSeeTableRecords($nodes); +}); + +it('displays the create button in the table instead of the header when 0 nodes', function () { + [$admin] = generateTestAccount([]); + $admin = $admin->syncRoles(Role::getRootAdmin()); + + // Nuke servers & nodes + Server::truncate(); + Node::truncate(); + + $this->actingAs($admin); + livewire(ListNodes::class) + ->assertSuccessful() + ->assertHeaderMissing(CreateAction::class) + ->assertActionExists(TableCreateAction::class); +}); diff --git a/tests/Pest.php b/tests/Pest.php index fd279ada6..c396b3bb7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -24,10 +24,26 @@ | */ +use App\Models\ActivityLog; +use App\Models\Allocation; +use App\Models\Egg; +use App\Models\Node; +use App\Models\Server; +use App\Models\Subuser; +use App\Models\User; +use App\Tests\Integration\IntegrationTestCase; +use Ramsey\Uuid\Uuid; + expect()->extend('toBeOne', function () { return $this->toBe(1); }); +expect()->extend('toLogActivities', function (int $times) { + expect(ActivityLog::count())->toBe($times); +}); + +uses(IntegrationTestCase::class)->in('Feature', 'Filament'); + /* |-------------------------------------------------------------------------- | Functions @@ -43,3 +59,119 @@ function something() { // .. } + +/** + * Generates a user and a server for that user. If an array of permissions is passed it + * is assumed that the user is actually a subuser of the server. + * + * @param string[] $permissions + * @return array{\App\Models\User, \App\Models\Server} + */ + +/** + * Creates a server model in the databases for the purpose of testing. If an attribute + * is passed in that normally requires this function to create a model no model will be + * created and that attribute's value will be used. + * + * The returned server model will have all the relationships loaded onto it. + */ +function createServerModel(array $attributes = []): Server +{ + if (isset($attributes['user_id'])) { + $attributes['owner_id'] = $attributes['user_id']; + } + + if (!isset($attributes['owner_id'])) { + /** @var \App\Models\User $user */ + $user = User::factory()->create(); + $attributes['owner_id'] = $user->id; + } + + if (!isset($attributes['node_id'])) { + /** @var \App\Models\Node $node */ + $node = Node::factory()->create(); + $attributes['node_id'] = $node->id; + } + + if (!isset($attributes['allocation_id'])) { + /** @var \App\Models\Allocation $allocation */ + $allocation = Allocation::factory()->create(['node_id' => $attributes['node_id']]); + $attributes['allocation_id'] = $allocation->id; + } + + if (empty($attributes['egg_id'])) { + $egg = getBungeecordEgg(); + + $attributes['egg_id'] = $egg->id; + } + + unset($attributes['user_id']); + + /** @var \App\Models\Server $server */ + $server = Server::factory()->create($attributes); + + Allocation::query()->where('id', $server->allocation_id)->update(['server_id' => $server->id]); + + return $server->fresh([ + 'user', 'node', 'allocation', 'egg', + ]); +} + +/** + * Generates a user and a server for that user. If an array of permissions is passed it + * is assumed that the user is actually a subuser of the server. + * + * @param string[] $permissions + * @return array{\App\Models\User, \App\Models\Server} + */ +function generateTestAccount(array $permissions = []): array +{ + /** @var \App\Models\User $user */ + $user = User::factory()->create(); + + if (empty($permissions)) { + return [$user, createServerModel(['user_id' => $user->id])]; + } + + $server = createServerModel(); + + Subuser::query()->create([ + 'user_id' => $user->id, + 'server_id' => $server->id, + 'permissions' => $permissions, + ]); + + return [$user, $server]; +} + +/** + * Clones a given egg allowing us to make modifications that don't affect other + * tests that rely on the egg existing in the correct state. + */ +function cloneEggAndVariables(Egg $egg): Egg +{ + $model = $egg->replicate(['id', 'uuid']); + $model->uuid = Uuid::uuid4()->toString(); + $model->push(); + + /** @var \App\Models\Egg $model */ + $model = $model->fresh(); + + foreach ($egg->variables as $variable) { + $variable->replicate(['id', 'egg_id'])->forceFill(['egg_id' => $model->id])->push(); + } + + return $model->fresh(); +} + +/** + * Almost every test just assumes it is using BungeeCord — this is the critical + * egg model for all tests unless specified otherwise. + */ +function getBungeecordEgg(): Egg +{ + /** @var \App\Models\Egg $egg */ + $egg = Egg::query()->where('author', 'panel@example.com')->where('name', 'Bungeecord')->firstOrFail(); + + return $egg; +}