Pest Test Improvements (#1137)

Co-authored-by: Lance Pioch <git@lance.sh>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
This commit is contained in:
pelican-vehikl 2025-04-28 10:20:33 -04:00 committed by GitHub
parent b39a8186ae
commit 2046fa453a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 633 additions and 14 deletions

3
.gitignore vendored
View File

@ -24,8 +24,7 @@ yarn-error.log
/.vscode /.vscode
public/assets/manifest.json public/assets/manifest.json
/database/*.sqlite /database/*.sqlite*
/database/*.sqlite-journal
filament-monaco-editor/ filament-monaco-editor/
_ide_helper* _ide_helper*
/.phpstorm.meta.php /.phpstorm.meta.php

View File

@ -14,4 +14,24 @@ enum RolePermissionModels: string
case Server = 'server'; case Server = 'server';
case User = 'user'; case User = 'user';
case Webhook = 'webhook'; 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;
}
} }

View File

@ -36,26 +36,22 @@ class SettingsController extends ClientApiController
$name = $request->input('name'); $name = $request->input('name');
$description = $request->has('description') ? (string) $request->input('description') : $server->description; $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) { if ($server->name !== $name) {
Activity::event('server:settings.rename') Activity::event('server:settings.rename')
->property(['old' => $server->name, 'new' => $name]) ->property(['old' => $server->name, 'new' => $name])
->log(); ->log();
$server->name = $name;
} }
if ($server->description !== $description) { if ($server->description !== $description && config('panel.editable_server_descriptions')) {
Activity::event('server:settings.description') Activity::event('server:settings.description')
->property(['old' => $server->description, 'new' => $description]) ->property(['old' => $server->description, 'new' => $description])
->log(); ->log();
$server->description = $description;
} }
$server->save();
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }

View File

@ -4,12 +4,13 @@ namespace App\Models;
use App\Contracts\Validatable; use App\Contracts\Validatable;
use App\Traits\HasValidation; use App\Traits\HasValidation;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class Permission extends Model implements Validatable class Permission extends Model implements Validatable
{ {
use HasValidation; use HasFactory, HasValidation;
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\RolePermissionModels; use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes; use App\Enums\RolePermissionPrefixes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Spatie\Permission\Models\Role as BaseRole; use Spatie\Permission\Models\Role as BaseRole;
/** /**
@ -17,6 +18,8 @@ use Spatie\Permission\Models\Role as BaseRole;
*/ */
class Role extends BaseRole class Role extends BaseRole
{ {
use HasFactory;
public const RESOURCE_NAME = 'role'; public const RESOURCE_NAME = 'role';
public const ROOT_ADMIN = 'Root Admin'; public const ROOT_ADMIN = 'Root Admin';

View File

@ -56,6 +56,8 @@
"mockery/mockery": "^1.6.11", "mockery/mockery": "^1.6.11",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.7", "pestphp/pest": "^3.7",
"pestphp/pest-plugin-faker": "^3.0",
"pestphp/pest-plugin-livewire": "^3.0",
"spatie/laravel-ignition": "^2.9" "spatie/laravel-ignition": "^2.9"
}, },
"autoload": { "autoload": {

135
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "e78193e058fd9f763da97bbc11934c2d", "content-hash": "556cda6914cb34938f738f77ab4b6949",
"packages": [ "packages": [
{ {
"name": "abdelhamiderrahmouni/filament-monaco-editor", "name": "abdelhamiderrahmouni/filament-monaco-editor",
@ -13616,6 +13616,137 @@
], ],
"time": "2025-04-16T22:59:48+00:00" "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", "name": "pestphp/pest-plugin-mutate",
"version": "v3.0.5", "version": "v3.0.5",
@ -15775,5 +15906,5 @@
"platform-overrides": { "platform-overrides": {
"php": "8.2" "php": "8.2"
}, },
"plugin-api-version": "2.6.0" "plugin-api-version": "2.3.0"
} }

View File

@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use App\Models\Permission;
use Illuminate\Database\Eloquent\Factories\Factory;
class PermissionFactory extends Factory
{
protected $model = Permission::class;
public function definition(): array
{
return [
];
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\Role;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class RoleFactory extends Factory
{
protected $model = Role::class;
public function definition(): array
{
return [
'name' => $this->faker->name(),
'guard_name' => $this->faker->name(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
}
}

View File

@ -0,0 +1,179 @@
<?php
use App\Enums\ServerState;
use App\Http\Controllers\Api\Client\Servers\SettingsController;
use App\Models\Permission;
use App\Repositories\Daemon\DaemonServerRepository;
use Symfony\Component\HttpFoundation\Response;
pest()->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);
});

View File

@ -0,0 +1,49 @@
<?php
use App\Enums\RolePermissionModels;
use App\Filament\Admin\Resources\EggResource\Pages\ListEggs;
use App\Models\Egg;
use App\Models\Permission;
use App\Models\Role;
use function Pest\Livewire\livewire;
it('root admin can see all eggs', function () {
$eggs = Egg::all();
[$admin] = generateTestAccount([]);
$admin = $admin->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);
});

View File

@ -0,0 +1,67 @@
<?php
use App\Enums\RolePermissionModels;
use App\Filament\Admin\Resources\NodeResource\Pages\ListNodes;
use App\Models\Node;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Server;
use Filament\Actions\CreateAction;
use Filament\Tables\Actions\CreateAction as TableCreateAction;
use function Pest\Livewire\livewire;
it('root admin can see all nodes', function () {
[$admin] = generateTestAccount([]);
$admin = $admin->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);
});

View File

@ -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 () { expect()->extend('toBeOne', function () {
return $this->toBe(1); return $this->toBe(1);
}); });
expect()->extend('toLogActivities', function (int $times) {
expect(ActivityLog::count())->toBe($times);
});
uses(IntegrationTestCase::class)->in('Feature', 'Filament');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Functions | 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;
}